use std::path::Path;
use serde::{Deserialize, Serialize};
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Cap: u8 {
const READ = 0b0000_0001;
const WRITE = 0b0000_0010;
const CREATE = 0b0000_0100;
const DELETE = 0b0000_1000;
const EXECUTE = 0b0001_0000;
}
}
impl Cap {
pub fn parse(s: &str) -> Result<Cap, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty capability expression".into());
}
let mut add = Cap::empty();
let mut sub = Cap::empty();
let mut op = '+';
let mut start = 0;
let bytes = s.as_bytes();
for i in 0..=bytes.len() {
let at_op = i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-');
if at_op || i == bytes.len() {
let part = s[start..i].trim();
if !part.is_empty() {
let cap = Self::parse_single(part)?;
match op {
'+' => add |= cap,
'-' => sub |= cap,
_ => unreachable!(),
}
}
if at_op {
op = bytes[i] as char;
start = i + 1;
}
}
}
let result = add & !sub;
if result.is_empty() {
return Err("capability expression resolves to empty set".into());
}
Ok(result)
}
pub fn to_list(&self) -> Vec<&'static str> {
let mut names = Vec::new();
if self.contains(Cap::READ) {
names.push("read");
}
if self.contains(Cap::WRITE) {
names.push("write");
}
if self.contains(Cap::CREATE) {
names.push("create");
}
if self.contains(Cap::DELETE) {
names.push("delete");
}
if self.contains(Cap::EXECUTE) {
names.push("execute");
}
names
}
pub fn parse_single(s: &str) -> Result<Cap, String> {
match s {
"read" => Ok(Cap::READ),
"write" => Ok(Cap::WRITE),
"create" => Ok(Cap::CREATE),
"delete" => Ok(Cap::DELETE),
"execute" => Ok(Cap::EXECUTE),
"full" | "all" => Ok(Cap::all()),
other => Err(format!("unknown capability: '{}'", other)),
}
}
pub fn display(&self) -> String {
let mut parts = Vec::new();
if self.contains(Cap::READ) {
parts.push("read");
}
if self.contains(Cap::WRITE) {
parts.push("write");
}
if self.contains(Cap::CREATE) {
parts.push("create");
}
if self.contains(Cap::DELETE) {
parts.push("delete");
}
if self.contains(Cap::EXECUTE) {
parts.push("execute");
}
parts.join(" + ")
}
pub fn short(&self) -> String {
let mut s = String::with_capacity(5);
s.push(if self.contains(Cap::READ) { 'r' } else { '-' });
s.push(if self.contains(Cap::WRITE) { 'w' } else { '-' });
s.push(if self.contains(Cap::CREATE) { 'c' } else { '-' });
s.push(if self.contains(Cap::DELETE) { 'd' } else { '-' });
s.push(if self.contains(Cap::EXECUTE) {
'x'
} else {
'-'
});
s
}
}
impl Serialize for Cap {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let names = self.to_list();
let mut seq = serializer.serialize_seq(Some(names.len()))?;
for name in &names {
seq.serialize_element(name)?;
}
seq.end()
}
}
impl<'de> Deserialize<'de> for Cap {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de;
struct CapVisitor;
impl<'de> de::Visitor<'de> for CapVisitor {
type Value = Cap;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(r#"a list of capabilities like ["read", "write"]"#)
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Cap, A::Error> {
let mut caps = Cap::empty();
while let Some(name) = seq.next_element::<String>()? {
caps |= Cap::parse_single(&name).map_err(de::Error::custom)?;
}
if caps.is_empty() {
return Err(de::Error::custom("capability list must not be empty"));
}
Ok(caps)
}
}
deserializer.deserialize_any(CapVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxPolicy {
pub default: Cap,
#[serde(default)]
pub rules: Vec<SandboxRule>,
#[serde(default)]
pub network: NetworkPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxRule {
pub effect: RuleEffect,
pub caps: Cap,
pub path: String,
#[serde(default)]
pub path_match: PathMatch,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub follow_worktrees: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PathMatch {
#[default]
Subpath,
Literal,
Regex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleEffect {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum NetworkPolicy {
#[default]
Deny,
Allow,
Localhost,
AllowDomains(Vec<String>),
}
impl Serialize for NetworkPolicy {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
NetworkPolicy::Deny => serializer.serialize_str("deny"),
NetworkPolicy::Allow => serializer.serialize_str("allow"),
NetworkPolicy::Localhost => serializer.serialize_str("localhost"),
NetworkPolicy::AllowDomains(domains) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("allow_domains", domains)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for NetworkPolicy {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de;
struct NetworkPolicyVisitor;
impl<'de> de::Visitor<'de> for NetworkPolicyVisitor {
type Value = NetworkPolicy;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(r#""deny", "allow", "localhost", or {"allow_domains": [...]}"#)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<NetworkPolicy, E> {
match value {
"deny" => Ok(NetworkPolicy::Deny),
"allow" => Ok(NetworkPolicy::Allow),
"localhost" => Ok(NetworkPolicy::Localhost),
other => Err(de::Error::unknown_variant(
other,
&["deny", "allow", "localhost"],
)),
}
}
fn visit_map<A: de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<NetworkPolicy, A::Error> {
let key: String = map
.next_key()?
.ok_or_else(|| de::Error::custom("expected allow_domains key"))?;
if key == "allow_domains" {
let domains: Vec<String> = map.next_value()?;
Ok(NetworkPolicy::AllowDomains(domains))
} else {
Err(de::Error::unknown_field(&key, &["allow_domains"]))
}
}
}
deserializer.deserialize_any(NetworkPolicyVisitor)
}
}
pub(crate) fn resolve_symlinks(path: &str) -> String {
use std::collections::VecDeque;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
let path = Path::new(path);
if !path.is_absolute() {
return path.to_string_lossy().into_owned();
}
let mut pending: VecDeque<OsString> = path
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_owned()),
_ => None,
})
.collect();
let mut resolved = PathBuf::from("/");
let mut symlink_depth: usize = 0;
const MAX_SYMLINK_DEPTH: usize = 40;
while let Some(component) = pending.pop_front() {
resolved.push(&component);
if let Ok(target) = std::fs::read_link(&resolved) {
symlink_depth += 1;
if symlink_depth > MAX_SYMLINK_DEPTH {
return path.to_string_lossy().into_owned();
}
if target.is_absolute() {
resolved = PathBuf::from("/");
} else {
resolved.pop();
}
let target_components: Vec<OsString> = target
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_owned()),
_ => None,
})
.collect();
for (i, tc) in target_components.into_iter().enumerate() {
pending.insert(i, tc);
}
}
}
resolved.to_string_lossy().into_owned()
}
impl SandboxPolicy {
pub fn resolve_path(path: &str, cwd: &str) -> String {
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let tmpdir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
super::path::PathResolver::new(cwd, home, tmpdir).resolve_env_vars(path)
}
pub fn expand_worktree_rules(&self, cwd: &Path) -> SandboxPolicy {
let has_worktree_rules = self.rules.iter().any(|r| r.follow_worktrees);
if !has_worktree_rules {
return self.clone();
}
let wt_paths = crate::git::worktree_sandbox_paths(cwd);
if wt_paths.is_empty() {
return self.clone();
}
let mut expanded = self.rules.clone();
for rule in &self.rules {
if !rule.follow_worktrees {
continue;
}
for path in &wt_paths {
expanded.push(SandboxRule {
effect: rule.effect,
caps: rule.caps,
path: path.clone(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
});
}
}
SandboxPolicy {
rules: expanded,
..self.clone()
}
}
pub fn effective_caps(&self, path: &str, cwd: &str) -> Cap {
struct MatchedRule {
effect: RuleEffect,
caps: Cap,
depth: usize,
}
let mut matched: Vec<MatchedRule> = Vec::new();
let canonical_path = resolve_symlinks(path);
for rule in &self.rules {
let rule_path = Self::resolve_path(&rule.path, cwd);
let canonical_rule = resolve_symlinks(&rule_path);
let matches = match rule.path_match {
PathMatch::Subpath => {
canonical_path.starts_with(&canonical_rule) || canonical_path == canonical_rule
}
PathMatch::Literal => canonical_path == canonical_rule,
PathMatch::Regex => regex::Regex::new(&rule_path)
.map(|re| re.is_match(path))
.unwrap_or(false),
};
if matches {
matched.push(MatchedRule {
effect: rule.effect,
caps: rule.caps,
depth: rule_path.matches('/').count(),
});
}
}
matched.sort_by(|a, b| {
a.depth.cmp(&b.depth).then_with(|| {
let effect_ord = |e: &RuleEffect| match e {
RuleEffect::Deny => 0,
RuleEffect::Allow => 1,
};
effect_ord(&a.effect).cmp(&effect_ord(&b.effect))
})
});
let mut result = self.default;
for rule in &matched {
match rule.effect {
RuleEffect::Allow => result |= rule.caps,
RuleEffect::Deny => result &= !rule.caps,
}
}
result
}
pub fn explain_denial(&self, path: &str, cwd: &str, required: Cap) -> Option<String> {
let effective = self.effective_caps(path, cwd);
if effective.contains(required) {
return None;
}
let mut best: Option<(&SandboxRule, String)> = None;
let mut best_depth: usize = 0;
let canonical_path = resolve_symlinks(path);
for rule in &self.rules {
if rule.effect != RuleEffect::Deny {
continue;
}
if (rule.caps & required).is_empty() {
continue;
}
let rule_path = Self::resolve_path(&rule.path, cwd);
let canonical_rule = resolve_symlinks(&rule_path);
let matches = match rule.path_match {
PathMatch::Subpath => {
canonical_path.starts_with(&canonical_rule) || canonical_path == canonical_rule
}
PathMatch::Literal => canonical_path == canonical_rule,
PathMatch::Regex => regex::Regex::new(&rule_path)
.map(|re| re.is_match(path))
.unwrap_or(false),
};
if matches {
let depth = rule_path.matches('/').count();
if best.is_none() || depth >= best_depth {
best_depth = depth;
best = Some((rule, rule_path));
}
}
}
if let Some((rule, resolved_path)) = best {
let match_type = match rule.path_match {
PathMatch::Subpath => "subpath",
PathMatch::Literal => "literal",
PathMatch::Regex => "regex",
};
Some(format!(
"deny {} in {} ({})",
rule.caps.short(),
resolved_path,
match_type,
))
} else {
Some("no allow rule grants access to this path".to_string())
}
}
}
pub fn parse_sandbox_rule(s: &str) -> Result<SandboxRule, String> {
let parts: Vec<&str> = s.splitn(2, " in ").collect();
if parts.len() != 2 {
return Err(format!(
"expected '<effect> <caps> in <path>', got: '{}'",
s
));
}
let caps_part = parts[0].trim();
let path = parts[1].trim().to_string();
let (effect_str, caps_str) = caps_part.split_once(char::is_whitespace).ok_or_else(|| {
format!(
"expected 'allow <caps>' or 'deny <caps>', got: '{}'",
caps_part
)
})?;
let effect = match effect_str.trim() {
"allow" => RuleEffect::Allow,
"deny" => RuleEffect::Deny,
other => return Err(format!("expected 'allow' or 'deny', got: '{}'", other)),
};
let caps = Cap::parse(caps_str.trim())?;
Ok(SandboxRule {
effect,
caps,
path,
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cap_parse() {
assert_eq!(Cap::parse("read").unwrap(), Cap::READ);
assert_eq!(Cap::parse("read + write").unwrap(), Cap::READ | Cap::WRITE);
assert_eq!(
Cap::parse("read + write + create + delete + execute").unwrap(),
Cap::all()
);
assert_eq!(Cap::parse("full").unwrap(), Cap::all());
assert_eq!(Cap::parse("full + read").unwrap(), Cap::all()); assert!(Cap::parse("unknown").is_err());
assert!(Cap::parse("").is_err());
}
#[test]
fn test_cap_parse_all_keyword() {
assert_eq!(Cap::parse("all").unwrap(), Cap::all());
assert_eq!(Cap::parse("all + read").unwrap(), Cap::all()); }
#[test]
fn test_cap_parse_subtraction() {
assert_eq!(
Cap::parse("all - write").unwrap(),
Cap::READ | Cap::CREATE | Cap::DELETE | Cap::EXECUTE
);
assert_eq!(
Cap::parse("full - write").unwrap(),
Cap::READ | Cap::CREATE | Cap::DELETE | Cap::EXECUTE
);
assert_eq!(
Cap::parse("all - write - delete").unwrap(),
Cap::READ | Cap::CREATE | Cap::EXECUTE
);
assert!(Cap::parse("all - all").is_err());
assert!(Cap::parse("read - read").is_err());
assert_eq!(
Cap::parse("read + write - execute").unwrap(),
Cap::READ | Cap::WRITE
);
assert_eq!(
Cap::parse("all-write").unwrap(),
Cap::READ | Cap::CREATE | Cap::DELETE | Cap::EXECUTE
);
}
#[test]
fn test_cap_short() {
assert_eq!(Cap::READ.short(), "r----");
assert_eq!((Cap::READ | Cap::WRITE).short(), "rw---");
assert_eq!((Cap::READ | Cap::EXECUTE).short(), "r---x");
assert_eq!(Cap::all().short(), "rwcdx");
assert_eq!(Cap::empty().short(), "-----");
}
#[test]
fn test_cap_display() {
assert_eq!(Cap::READ.display(), "read");
assert_eq!((Cap::READ | Cap::WRITE).display(), "read + write");
assert_eq!(
Cap::all().display(),
"read + write + create + delete + execute"
);
}
#[test]
fn test_cap_serde_roundtrip() {
let caps = Cap::READ | Cap::WRITE | Cap::CREATE;
let json = serde_json::to_string(&caps).unwrap();
assert_eq!(json, r#"["read","write","create"]"#);
let deserialized: Cap = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, caps);
}
#[test]
fn test_cap_deserialize_string_rejected() {
let result: Result<Cap, _> = serde_json::from_str(r#""read + write""#);
assert!(result.is_err());
}
#[test]
fn test_parse_sandbox_rule() {
let rule = parse_sandbox_rule("allow read + write in $PWD").unwrap();
assert_eq!(rule.effect, RuleEffect::Allow);
assert_eq!(rule.caps, Cap::READ | Cap::WRITE);
assert_eq!(rule.path, "$PWD");
assert_eq!(rule.path_match, PathMatch::Subpath);
}
#[test]
fn test_parse_sandbox_rule_deny() {
let rule = parse_sandbox_rule("deny delete in $PWD/.git").unwrap();
assert_eq!(rule.effect, RuleEffect::Deny);
assert_eq!(rule.caps, Cap::DELETE);
assert_eq!(rule.path, "$PWD/.git");
}
#[test]
fn test_effective_caps() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ | Cap::WRITE | Cap::CREATE | Cap::DELETE,
path: "/project".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE | Cap::DELETE | Cap::CREATE,
path: "/project/.git".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/etc/passwd", "/project");
assert_eq!(caps, Cap::READ | Cap::EXECUTE);
let caps = policy.effective_caps("/project/src/main.rs", "/project");
assert_eq!(
caps,
Cap::READ | Cap::WRITE | Cap::CREATE | Cap::DELETE | Cap::EXECUTE
);
let caps = policy.effective_caps("/project/.git/config", "/project");
assert_eq!(caps, Cap::READ | Cap::EXECUTE);
}
#[test]
fn test_network_policy_localhost_serde() {
let json = serde_json::to_string(&NetworkPolicy::Localhost).unwrap();
assert_eq!(json, r#""localhost""#);
let deserialized: NetworkPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, NetworkPolicy::Localhost);
}
#[test]
fn test_sandbox_policy_serde() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ | Cap::WRITE | Cap::CREATE | Cap::DELETE,
path: "$PWD".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let json = serde_json::to_string(&policy).unwrap();
let deserialized: SandboxPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.default, policy.default);
assert_eq!(deserialized.rules.len(), 1);
assert_eq!(deserialized.network, NetworkPolicy::Deny);
}
#[test]
fn resolve_path_pwd_replacement() {
let result = SandboxPolicy::resolve_path("$PWD/src", "/my/project");
assert_eq!(result, "/my/project/src");
}
#[test]
fn resolve_path_home_replacement() {
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let result = SandboxPolicy::resolve_path("$HOME/.config", "/ignored");
assert_eq!(result, format!("{}/.config", home));
}
#[test]
fn resolve_path_tmpdir_replacement() {
unsafe { std::env::set_var("TMPDIR", "/custom/tmp") };
let result = SandboxPolicy::resolve_path("$TMPDIR/scratch", "/ignored");
assert_eq!(result, "/custom/tmp/scratch");
unsafe { std::env::remove_var("TMPDIR") };
}
#[test]
fn resolve_path_tmpdir_fallback() {
unsafe { std::env::remove_var("TMPDIR") };
let result = SandboxPolicy::resolve_path("$TMPDIR/scratch", "/ignored");
assert_eq!(result, "/tmp/scratch");
}
#[test]
fn resolve_path_multiple_vars() {
unsafe { std::env::set_var("TMPDIR", "/var/tmp") };
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let result = SandboxPolicy::resolve_path("$PWD:$HOME:$TMPDIR", "/work");
assert_eq!(result, format!("/work:{}:/var/tmp", home));
unsafe { std::env::remove_var("TMPDIR") };
}
#[test]
fn resolve_path_no_variables() {
let result = SandboxPolicy::resolve_path("/usr/local/bin", "/ignored");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn effective_caps_regex_path_match() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: r"/project/.*\.rs$".into(),
path_match: PathMatch::Regex,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/project/src/main.rs", "/project");
assert_eq!(caps, Cap::READ | Cap::WRITE);
let caps = policy.effective_caps("/project/src/main.py", "/project");
assert_eq!(caps, Cap::READ);
}
#[test]
fn effective_caps_literal_path_match() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/etc/hosts".into(),
path_match: PathMatch::Literal,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/etc/hosts", "/ignored");
assert_eq!(caps, Cap::READ | Cap::WRITE);
let caps = policy.effective_caps("/etc/hosts/foo", "/ignored");
assert_eq!(caps, Cap::READ);
}
#[test]
fn effective_caps_allow_wins_at_same_depth() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE | Cap::CREATE,
path: "/data".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE,
path: "/data".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/data/file.txt", "/ignored");
assert_eq!(caps, Cap::READ | Cap::WRITE | Cap::CREATE);
}
#[test]
fn effective_caps_multiple_overlapping_rules() {
let policy = SandboxPolicy {
default: Cap::empty() | Cap::READ,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::all(),
path: "/project".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::DELETE,
path: "/project/.git".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE | Cap::CREATE,
path: "/project/.git".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/project/src/lib.rs", "/project");
assert_eq!(caps, Cap::all());
let caps = policy.effective_caps("/project/.git/HEAD", "/project");
assert_eq!(caps, Cap::READ | Cap::EXECUTE);
}
#[test]
fn effective_caps_default_when_no_rules_match() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/specific/path".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/unrelated/path", "/ignored");
assert_eq!(caps, Cap::READ | Cap::EXECUTE);
}
#[test]
fn effective_caps_deeper_allow_overrides_shallower_deny() {
let policy = SandboxPolicy {
default: Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ | Cap::WRITE | Cap::CREATE,
path: "/Users/eliot/code/project".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ,
path: "/Users/eliot".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ,
path: "/".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ | Cap::WRITE | Cap::CREATE | Cap::DELETE | Cap::EXECUTE,
path: "/Users".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/Users/eliot/code/project/src/main.rs", "/ignored");
assert_eq!(caps, Cap::READ | Cap::WRITE | Cap::CREATE);
let caps = policy.effective_caps("/Users/eliot/.config/foo", "/ignored");
assert_eq!(caps, Cap::READ);
let caps = policy.effective_caps("/etc/passwd", "/ignored");
assert_eq!(caps, Cap::READ | Cap::EXECUTE);
}
#[test]
fn expand_worktree_no_worktree_rules_unchanged() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "$PWD".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let expanded = policy.expand_worktree_rules(Path::new("/any/path"));
assert_eq!(expanded.rules.len(), 1);
}
#[test]
fn expand_worktree_not_in_worktree_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("normal-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "$PWD".into(),
path_match: PathMatch::Subpath,
follow_worktrees: true,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let expanded = policy.expand_worktree_rules(&repo);
assert_eq!(expanded.rules.len(), 1);
}
#[test]
fn expand_worktree_adds_git_dir_rules() {
let tmp = tempfile::tempdir().unwrap();
let main_repo = tmp.path().join("main-repo");
let git_dir = main_repo.join(".git");
let wt_git = git_dir.join("worktrees").join("feature");
std::fs::create_dir_all(&wt_git).unwrap();
std::fs::write(wt_git.join("commondir"), "../..").unwrap();
std::fs::write(wt_git.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
let worktree = tmp.path().join("feature-worktree");
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}", wt_git.display()),
)
.unwrap();
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ | Cap::WRITE,
path: "$PWD".into(),
path_match: PathMatch::Subpath,
follow_worktrees: true,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::DELETE,
path: "/etc".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
};
let expanded = policy.expand_worktree_rules(&worktree);
assert_eq!(expanded.rules.len(), 4);
let new_rules: Vec<_> = expanded.rules[2..].to_vec();
for rule in &new_rules {
assert_eq!(rule.effect, RuleEffect::Allow);
assert_eq!(rule.caps, Cap::READ | Cap::WRITE);
assert_eq!(rule.path_match, PathMatch::Subpath);
assert!(!rule.follow_worktrees);
}
assert_eq!(expanded.rules[1].path, "/etc");
}
#[test]
fn expand_worktree_follow_worktrees_not_serialized_when_false() {
let rule = SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ,
path: "$PWD".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
};
let json = serde_json::to_string(&rule).unwrap();
assert!(!json.contains("follow_worktrees"));
}
#[test]
fn expand_worktree_follow_worktrees_serialized_when_true() {
let rule = SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::READ,
path: "$PWD".into(),
path_match: PathMatch::Subpath,
follow_worktrees: true,
doc: None,
};
let json = serde_json::to_string(&rule).unwrap();
assert!(json.contains("\"follow_worktrees\":true"));
}
#[test]
fn resolve_symlinks_unrelated_path_unchanged() {
assert_eq!(resolve_symlinks("/usr/local/bin"), "/usr/local/bin");
}
#[test]
fn resolve_symlinks_follows_real_symlink() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("target_dir");
std::fs::create_dir(&target).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let resolved = resolve_symlinks(&format!("{}/child", link.display()));
let expected = format!("{}/child", resolve_symlinks(&target.to_string_lossy()));
assert_eq!(resolved, expected);
}
#[test]
fn resolve_symlinks_nonexistent_path_returned_as_is() {
let result = resolve_symlinks("/nonexistent/made/up/path");
assert_eq!(result, "/nonexistent/made/up/path");
}
#[cfg(target_os = "macos")]
#[test]
fn resolve_symlinks_macos_var() {
assert_eq!(
resolve_symlinks("/var/folders/xx"),
"/private/var/folders/xx"
);
}
#[cfg(target_os = "macos")]
#[test]
fn resolve_symlinks_macos_tmp() {
assert_eq!(resolve_symlinks("/tmp/build"), "/private/tmp/build");
}
#[cfg(target_os = "macos")]
#[test]
fn resolve_symlinks_macos_etc() {
assert_eq!(resolve_symlinks("/etc/hosts"), "/private/etc/hosts");
}
#[cfg(target_os = "macos")]
#[test]
fn resolve_symlinks_macos_exact() {
assert_eq!(resolve_symlinks("/var"), "/private/var");
assert_eq!(resolve_symlinks("/tmp"), "/private/tmp");
assert_eq!(resolve_symlinks("/etc"), "/private/etc");
}
#[cfg(target_os = "macos")]
#[test]
fn resolve_symlinks_macos_already_private() {
assert_eq!(
resolve_symlinks("/private/var/folders"),
"/private/var/folders"
);
}
#[test]
fn effective_caps_symlink_rule_matches_resolved_query() {
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real");
std::fs::create_dir(&real_dir).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real_dir, &link).unwrap();
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: link.to_string_lossy().into_owned(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let query = format!("{}/file.txt", real_dir.display());
let caps = policy.effective_caps(&query, "/ignored");
assert!(
caps.contains(Cap::WRITE),
"rule on symlink path should match query via resolved path"
);
}
#[test]
fn effective_caps_resolved_rule_matches_symlink_query() {
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real");
std::fs::create_dir(&real_dir).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real_dir, &link).unwrap();
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: real_dir.to_string_lossy().into_owned(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let query = format!("{}/file.txt", link.display());
let caps = policy.effective_caps(&query, "/ignored");
assert!(
caps.contains(Cap::WRITE),
"rule on real path should match query via symlink"
);
}
#[test]
fn effective_caps_symlink_deny() {
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real");
std::fs::create_dir(&real_dir).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real_dir, &link).unwrap();
let policy = SandboxPolicy {
default: Cap::READ | Cap::WRITE,
rules: vec![SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE,
path: link.to_string_lossy().into_owned(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let query = format!("{}/file.txt", real_dir.display());
let caps = policy.effective_caps(&query, "/ignored");
assert!(
!caps.contains(Cap::WRITE),
"deny on symlink should apply to resolved path"
);
}
#[test]
fn explain_denial_across_symlink() {
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real");
std::fs::create_dir(&real_dir).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real_dir, &link).unwrap();
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ,
path: link.to_string_lossy().into_owned(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let query = format!("{}/secret", real_dir.display());
let explanation = policy.explain_denial(&query, "/ignored", Cap::READ);
assert!(
explanation.is_some(),
"deny on symlink should explain denial for resolved path"
);
}
#[cfg(target_os = "macos")]
#[test]
fn effective_caps_macos_var_symlink_duality() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/var/folders".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/private/var/folders/xx/data", "/ignored");
assert!(
caps.contains(Cap::WRITE),
"rule on /var/folders should match query for /private/var/folders/xx/data"
);
}
#[cfg(target_os = "macos")]
#[test]
fn effective_caps_macos_private_rule_matches_symlink_query() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/private/tmp".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
let caps = policy.effective_caps("/tmp/scratch", "/ignored");
assert!(
caps.contains(Cap::WRITE),
"rule on /private/tmp should match query for /tmp/scratch"
);
}
}