use crate::error::{NonoError, Result};
use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CapabilitySource {
#[default]
User,
Profile,
Group(String),
System,
}
impl CapabilitySource {
#[must_use]
pub fn is_user_intent(&self) -> bool {
matches!(self, CapabilitySource::User | CapabilitySource::Profile)
}
}
impl std::fmt::Display for CapabilitySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CapabilitySource::User => write!(f, "user"),
CapabilitySource::Profile => write!(f, "profile"),
CapabilitySource::Group(name) => write!(f, "group:{}", name),
CapabilitySource::System => write!(f, "system"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccessMode {
Read,
Write,
ReadWrite,
}
impl AccessMode {
#[must_use]
pub fn contains(self, required: AccessMode) -> bool {
match self {
AccessMode::ReadWrite => true,
AccessMode::Read => required == AccessMode::Read,
AccessMode::Write => required == AccessMode::Write,
}
}
}
impl std::fmt::Display for AccessMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AccessMode::Read => write!(f, "read"),
AccessMode::Write => write!(f, "write"),
AccessMode::ReadWrite => write!(f, "read+write"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FsCapability {
pub original: PathBuf,
pub resolved: PathBuf,
pub access: AccessMode,
pub is_file: bool,
#[serde(default)]
pub source: CapabilitySource,
}
impl FsCapability {
pub fn new_dir(path: impl AsRef<Path>, access: AccessMode) -> Result<Self> {
let path = path.as_ref();
let resolved = path.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
NonoError::PathNotFound(path.to_path_buf())
} else {
NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: e,
}
}
})?;
if !resolved.is_dir() {
return Err(NonoError::ExpectedDirectory(path.to_path_buf()));
}
Ok(Self {
original: path.to_path_buf(),
resolved,
access,
is_file: false,
source: CapabilitySource::User,
})
}
pub fn new_file(path: impl AsRef<Path>, access: AccessMode) -> Result<Self> {
let path = path.as_ref();
let resolved = path.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
NonoError::PathNotFound(path.to_path_buf())
} else {
NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: e,
}
}
})?;
if resolved.is_dir() {
return Err(NonoError::ExpectedFile(path.to_path_buf()));
}
Ok(Self {
original: path.to_path_buf(),
resolved,
access,
is_file: true,
source: CapabilitySource::User,
})
}
}
impl std::fmt::Display for FsCapability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.resolved.display(), self.access)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnixSocketMode {
Connect,
ConnectBind,
}
impl UnixSocketMode {
#[must_use]
pub fn permits_bind(self) -> bool {
matches!(self, UnixSocketMode::ConnectBind)
}
}
impl std::fmt::Display for UnixSocketMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnixSocketMode::Connect => write!(f, "connect"),
UnixSocketMode::ConnectBind => write!(f, "connect+bind"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnixSocketOp {
Connect,
Bind,
}
impl std::fmt::Display for UnixSocketOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnixSocketOp::Connect => write!(f, "connect"),
UnixSocketOp::Bind => write!(f, "bind"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnixSocketCapability {
pub original: PathBuf,
pub resolved: PathBuf,
pub is_directory: bool,
pub mode: UnixSocketMode,
#[serde(default)]
pub source: CapabilitySource,
}
impl UnixSocketCapability {
pub fn new_file(path: impl AsRef<Path>, mode: UnixSocketMode) -> Result<Self> {
let path = path.as_ref();
let resolved = match path.canonicalize() {
Ok(p) if p.is_dir() => {
return Err(NonoError::ExpectedFile(path.to_path_buf()));
}
Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if !mode.permits_bind() {
return Err(NonoError::PathNotFound(path.to_path_buf()));
}
let parent = path
.parent()
.ok_or_else(|| NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"socket path has no parent directory",
),
})?;
let file_name =
path.file_name()
.ok_or_else(|| NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"socket path has no final component",
),
})?;
let resolved_parent = parent.canonicalize().map_err(|parent_err| {
if parent_err.kind() == std::io::ErrorKind::NotFound {
NonoError::PathNotFound(parent.to_path_buf())
} else {
NonoError::PathCanonicalization {
path: parent.to_path_buf(),
source: parent_err,
}
}
})?;
if !resolved_parent.is_dir() {
return Err(NonoError::ExpectedDirectory(parent.to_path_buf()));
}
resolved_parent.join(file_name)
}
Err(e) => {
return Err(NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: e,
});
}
};
Ok(Self {
original: path.to_path_buf(),
resolved,
is_directory: false,
mode,
source: CapabilitySource::User,
})
}
pub fn new_dir(path: impl AsRef<Path>, mode: UnixSocketMode) -> Result<Self> {
let path = path.as_ref();
let resolved = path.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
NonoError::PathNotFound(path.to_path_buf())
} else {
NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: e,
}
}
})?;
if !resolved.is_dir() {
return Err(NonoError::ExpectedDirectory(path.to_path_buf()));
}
if resolved.parent().is_none() {
return Err(NonoError::SandboxInit(
"unix socket directory grant at filesystem root is not permitted".to_string(),
));
}
Ok(Self {
original: path.to_path_buf(),
resolved,
is_directory: true,
mode,
source: CapabilitySource::User,
})
}
#[must_use]
pub fn covers(&self, sockaddr_path: &Path) -> bool {
if self.is_directory {
sockaddr_path.parent() == Some(self.resolved.as_path())
} else {
sockaddr_path == self.resolved.as_path()
}
}
}
impl std::fmt::Display for UnixSocketCapability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let scope = if self.is_directory { "dir " } else { "" };
write!(f, "{}{} ({})", scope, self.resolved.display(), self.mode)
}
}
fn validate_platform_rule(rule: &str) -> Result<()> {
let trimmed = rule.trim();
if !trimmed.starts_with('(') {
return Err(NonoError::SandboxInit(format!(
"platform rule must be an S-expression starting with '(': {}",
rule
)));
}
let tokens = tokenize_sexp(trimmed)?;
let mut depth: i32 = 0;
for tok in &tokens {
match tok.as_str() {
"(" => depth = depth.saturating_add(1),
")" => {
depth = depth.saturating_sub(1);
if depth < 0 {
return Err(NonoError::SandboxInit(format!(
"platform rule has unbalanced parentheses: {rule}"
)));
}
}
_ => {}
}
}
if depth != 0 {
return Err(NonoError::SandboxInit(format!(
"platform rule has unbalanced parentheses: {rule}"
)));
}
let content_tokens: Vec<&str> = tokens
.iter()
.map(String::as_str)
.filter(|t| *t != "(" && *t != ")")
.collect();
for window in content_tokens.windows(4) {
if window[0] == "allow"
&& (window[1] == "file-read*" || window[1] == "file-write*")
&& window[2] == "subpath"
&& window[3] == "/"
{
let kind = if window[1] == "file-read*" {
"read"
} else {
"write"
};
return Err(NonoError::SandboxInit(format!(
"platform rule must not grant root-level {kind} access"
)));
}
}
Ok(())
}
fn tokenize_sexp(input: &str) -> Result<Vec<String>> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
c if c.is_ascii_whitespace() => {
chars.next();
}
'#' => {
chars.next();
if chars.peek() == Some(&'|') {
chars.next();
let mut closed = false;
while let Some(cc) = chars.next() {
if cc == '|' && chars.peek() == Some(&'#') {
chars.next();
closed = true;
break;
}
}
if !closed {
return Err(NonoError::SandboxInit(
"platform rule has unterminated block comment".to_string(),
));
}
} else {
let mut tok = String::from('#');
while let Some(&nc) = chars.peek() {
if nc.is_ascii_whitespace() || nc == '(' || nc == ')' || nc == '"' {
break;
}
tok.push(nc);
chars.next();
}
tokens.push(tok);
}
}
';' => {
chars.next();
while let Some(&nc) = chars.peek() {
chars.next();
if nc == '\n' {
break;
}
}
}
'(' | ')' => {
tokens.push(String::from(c));
chars.next();
}
'"' => {
chars.next();
let mut s = String::new();
let mut closed = false;
while let Some(sc) = chars.next() {
if sc == '\\' {
if let Some(esc) = chars.next() {
s.push(esc);
}
} else if sc == '"' {
closed = true;
break;
} else {
s.push(sc);
}
}
if !closed {
return Err(NonoError::SandboxInit(
"platform rule has unterminated string".to_string(),
));
}
tokens.push(s);
}
_ => {
let mut tok = String::new();
while let Some(&nc) = chars.peek() {
if nc.is_ascii_whitespace() || nc == '(' || nc == ')' || nc == '"' {
break;
}
tok.push(nc);
chars.next();
}
tokens.push(tok);
}
}
}
Ok(tokens)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignalMode {
#[default]
Isolated,
AllowSameSandbox,
AllowAll,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProcessInfoMode {
#[default]
Isolated,
AllowSameSandbox,
AllowAll,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpcMode {
#[default]
SharedMemoryOnly,
Full,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum NetworkMode {
Blocked,
#[default]
AllowAll,
ProxyOnly {
port: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
bind_ports: Vec<u16>,
},
}
impl std::fmt::Display for NetworkMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NetworkMode::Blocked => write!(f, "blocked"),
NetworkMode::AllowAll => write!(f, "allowed"),
NetworkMode::ProxyOnly { port, bind_ports } => {
if bind_ports.is_empty() {
write!(f, "proxy-only (localhost:{})", port)
} else {
let ports_str: Vec<String> = bind_ports.iter().map(|p| p.to_string()).collect();
write!(
f,
"proxy-only (localhost:{}, bind: {})",
port,
ports_str.join(", ")
)
}
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CapabilitySet {
fs: Vec<FsCapability>,
unix_sockets: Vec<UnixSocketCapability>,
network_mode: NetworkMode,
tcp_connect_ports: Vec<u16>,
tcp_bind_ports: Vec<u16>,
localhost_ports: Vec<u16>,
allowed_commands: Vec<String>,
blocked_commands: Vec<String>,
platform_rules: Vec<String>,
signal_mode: SignalMode,
process_info_mode: ProcessInfoMode,
ipc_mode: IpcMode,
extensions_enabled: bool,
seatbelt_debug_deny: bool,
}
impl CapabilitySet {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn allow_path(mut self, path: impl AsRef<Path>, mode: AccessMode) -> Result<Self> {
let cap = FsCapability::new_dir(path, mode)?;
self.fs.push(cap);
Ok(self)
}
pub fn allow_file(mut self, path: impl AsRef<Path>, mode: AccessMode) -> Result<Self> {
let cap = FsCapability::new_file(path, mode)?;
self.fs.push(cap);
Ok(self)
}
pub fn allow_unix_socket(
mut self,
path: impl AsRef<Path>,
mode: UnixSocketMode,
) -> Result<Self> {
let cap = UnixSocketCapability::new_file(path, mode)?;
self.unix_sockets.push(cap);
Ok(self)
}
pub fn allow_unix_socket_dir(
mut self,
path: impl AsRef<Path>,
mode: UnixSocketMode,
) -> Result<Self> {
let cap = UnixSocketCapability::new_dir(path, mode)?;
self.unix_sockets.push(cap);
Ok(self)
}
#[must_use]
pub fn block_network(mut self) -> Self {
self.network_mode = NetworkMode::Blocked;
self
}
#[must_use]
pub fn set_network_mode(mut self, mode: NetworkMode) -> Self {
self.network_mode = mode;
self
}
#[must_use]
pub fn proxy_only(mut self, port: u16) -> Self {
self.network_mode = NetworkMode::ProxyOnly {
port,
bind_ports: Vec::new(),
};
self
}
#[must_use]
pub fn proxy_only_with_bind(mut self, proxy_port: u16, bind_ports: Vec<u16>) -> Self {
self.network_mode = NetworkMode::ProxyOnly {
port: proxy_port,
bind_ports,
};
self
}
#[must_use]
pub fn allow_tcp_connect(mut self, port: u16) -> Self {
self.tcp_connect_ports.push(port);
self
}
#[must_use]
pub fn allow_tcp_bind(mut self, port: u16) -> Self {
self.tcp_bind_ports.push(port);
self
}
#[must_use]
pub fn allow_localhost_port(mut self, port: u16) -> Self {
self.localhost_ports.push(port);
self
}
#[must_use]
pub fn allow_https(self) -> Self {
self.allow_tcp_connect(443).allow_tcp_connect(8443)
}
#[must_use]
pub fn set_signal_mode(mut self, mode: SignalMode) -> Self {
self.signal_mode = mode;
self
}
#[must_use]
pub fn set_process_info_mode(mut self, mode: ProcessInfoMode) -> Self {
self.process_info_mode = mode;
self
}
#[must_use]
pub fn set_ipc_mode(mut self, mode: IpcMode) -> Self {
self.ipc_mode = mode;
self
}
#[must_use]
pub fn allow_signals(mut self) -> Self {
self.signal_mode = SignalMode::AllowAll;
self
}
#[must_use]
pub fn enable_extensions(mut self) -> Self {
self.extensions_enabled = true;
self
}
#[must_use]
pub fn allow_command(mut self, cmd: impl Into<String>) -> Self {
self.allowed_commands.push(cmd.into());
self
}
#[must_use]
pub fn block_command(mut self, cmd: impl Into<String>) -> Self {
self.blocked_commands.push(cmd.into());
self
}
pub fn platform_rule(mut self, rule: impl Into<String>) -> Result<Self> {
let rule = rule.into();
validate_platform_rule(&rule)?;
self.platform_rules.push(rule);
Ok(self)
}
pub fn add_fs(&mut self, cap: FsCapability) {
self.fs.push(cap);
}
pub fn add_unix_socket(&mut self, cap: UnixSocketCapability) {
self.unix_sockets.push(cap);
}
pub fn set_network_blocked(&mut self, blocked: bool) {
self.network_mode = if blocked {
NetworkMode::Blocked
} else {
NetworkMode::AllowAll
};
}
pub fn set_network_mode_mut(&mut self, mode: NetworkMode) {
self.network_mode = mode;
}
pub fn set_signal_mode_mut(&mut self, mode: SignalMode) {
self.signal_mode = mode;
}
pub fn set_process_info_mode_mut(&mut self, mode: ProcessInfoMode) {
self.process_info_mode = mode;
}
pub fn set_ipc_mode_mut(&mut self, mode: IpcMode) {
self.ipc_mode = mode;
}
pub fn add_tcp_connect_port(&mut self, port: u16) {
self.tcp_connect_ports.push(port);
}
pub fn add_tcp_bind_port(&mut self, port: u16) {
self.tcp_bind_ports.push(port);
}
pub fn add_localhost_port(&mut self, port: u16) {
self.localhost_ports.push(port);
}
pub fn set_extensions_enabled(&mut self, enabled: bool) {
self.extensions_enabled = enabled;
}
pub fn set_seatbelt_debug_deny(&mut self, enabled: bool) {
self.seatbelt_debug_deny = enabled;
}
pub fn add_allowed_command(&mut self, cmd: impl Into<String>) {
self.allowed_commands.push(cmd.into());
}
pub fn add_blocked_command(&mut self, cmd: impl Into<String>) {
self.blocked_commands.push(cmd.into());
}
pub fn add_platform_rule(&mut self, rule: impl Into<String>) -> Result<()> {
let rule = rule.into();
validate_platform_rule(&rule)?;
self.platform_rules.push(rule);
Ok(())
}
pub fn remove_exact_file_caps_for_paths(&mut self, denied_paths: &[PathBuf]) -> usize {
let before = self.fs.len();
self.fs.retain(|cap| {
!cap.is_file
|| !denied_paths
.iter()
.any(|denied| cap.original == *denied || cap.resolved == *denied)
});
before.saturating_sub(self.fs.len())
}
#[must_use]
pub fn fs_capabilities(&self) -> &[FsCapability] {
&self.fs
}
#[must_use]
pub fn unix_socket_capabilities(&self) -> &[UnixSocketCapability] {
&self.unix_sockets
}
#[must_use]
pub fn unix_socket_allowed(&self, sockaddr_path: &Path, op: UnixSocketOp) -> bool {
self.unix_sockets.iter().any(|cap| {
cap.covers(sockaddr_path)
&& match op {
UnixSocketOp::Connect => true, UnixSocketOp::Bind => cap.mode.permits_bind(),
}
})
}
pub fn remap_procfs_self_references(&mut self, process_pid: u32, thread_pid: Option<u32>) {
for cap in &mut self.fs {
if let Some(rewritten) =
rewrite_procfs_self_reference(&cap.original, process_pid, thread_pid)
{
cap.resolved = rewritten;
}
}
self.deduplicate();
}
pub fn widen_procfs_self_to_proc(&mut self) {
for cap in &mut self.fs {
if cap.access == AccessMode::Read {
let is_proc_self_dir = cap
.original
.to_str()
.map(|s| s == "/proc/self" || s == "/proc/self/")
.unwrap_or(false);
if is_proc_self_dir {
cap.resolved = std::path::PathBuf::from("/proc");
}
}
}
self.deduplicate();
}
#[must_use]
pub fn is_network_blocked(&self) -> bool {
matches!(
self.network_mode,
NetworkMode::Blocked | NetworkMode::ProxyOnly { .. }
)
}
#[must_use]
pub fn signal_mode(&self) -> SignalMode {
self.signal_mode
}
#[must_use]
pub fn process_info_mode(&self) -> ProcessInfoMode {
self.process_info_mode
}
#[must_use]
pub fn ipc_mode(&self) -> IpcMode {
self.ipc_mode
}
#[must_use]
pub fn network_mode(&self) -> &NetworkMode {
&self.network_mode
}
#[must_use]
pub fn tcp_connect_ports(&self) -> &[u16] {
&self.tcp_connect_ports
}
#[must_use]
pub fn tcp_bind_ports(&self) -> &[u16] {
&self.tcp_bind_ports
}
#[must_use]
pub fn localhost_ports(&self) -> &[u16] {
&self.localhost_ports
}
#[must_use]
pub fn extensions_enabled(&self) -> bool {
self.extensions_enabled
}
#[must_use]
pub fn seatbelt_debug_deny(&self) -> bool {
self.seatbelt_debug_deny
}
#[must_use]
pub fn allowed_commands(&self) -> &[String] {
&self.allowed_commands
}
#[must_use]
pub fn blocked_commands(&self) -> &[String] {
&self.blocked_commands
}
#[must_use]
pub fn platform_rules(&self) -> &[String] {
&self.platform_rules
}
#[must_use]
pub fn has_fs(&self) -> bool {
!self.fs.is_empty()
}
pub fn deduplicate(&mut self) {
use std::collections::HashMap;
let mut seen: HashMap<(PathBuf, bool), usize> = HashMap::new();
let mut to_remove = Vec::new();
let mut original_updates: Vec<(usize, PathBuf)> = Vec::new();
let mut access_upgrades: Vec<(usize, AccessMode)> = Vec::new();
for (i, cap) in self.fs.iter().enumerate() {
let key = (cap.resolved.clone(), cap.is_file);
if let Some(&existing_idx) = seen.get(&key) {
let existing = &self.fs[existing_idx];
let new_is_user = cap.source.is_user_intent();
let existing_is_user = existing.source.is_user_intent();
let keep_new = if new_is_user && !existing_is_user {
true
} else if !new_is_user && existing_is_user {
false
} else {
cap.access == AccessMode::ReadWrite && existing.access != AccessMode::ReadWrite
};
let merged_access = match (existing.access, cap.access) {
(AccessMode::Read, AccessMode::Write)
| (AccessMode::Write, AccessMode::Read) => Some(AccessMode::ReadWrite),
_ => None,
};
if keep_new {
to_remove.push(existing_idx);
seen.insert(key, i);
if cap.original == cap.resolved && existing.original != existing.resolved {
original_updates.push((i, existing.original.clone()));
}
if let Some(access) = merged_access {
access_upgrades.push((i, access));
}
} else {
if existing.original == existing.resolved && cap.original != cap.resolved {
original_updates.push((existing_idx, cap.original.clone()));
}
to_remove.push(i);
if let Some(access) = merged_access {
access_upgrades.push((existing_idx, access));
}
}
} else {
seen.insert(key, i);
}
}
for (idx, original) in original_updates {
self.fs[idx].original = original;
}
for (idx, access) in access_upgrades {
self.fs[idx].access = access;
}
to_remove.sort_unstable();
to_remove.reverse();
for idx in to_remove {
self.fs.remove(idx);
}
self.deduplicate_unix_sockets();
}
fn deduplicate_unix_sockets(&mut self) {
use std::collections::HashMap;
let mut seen: HashMap<(PathBuf, bool), usize> = HashMap::new();
let mut to_remove: Vec<usize> = Vec::new();
let mut mode_upgrades: Vec<(usize, UnixSocketMode)> = Vec::new();
let mut original_updates: Vec<(usize, PathBuf)> = Vec::new();
for (i, cap) in self.unix_sockets.iter().enumerate() {
let key = (cap.resolved.clone(), cap.is_directory);
if let Some(&existing_idx) = seen.get(&key) {
let existing = &self.unix_sockets[existing_idx];
let new_is_user = cap.source.is_user_intent();
let existing_is_user = existing.source.is_user_intent();
let same_provenance = new_is_user == existing_is_user;
let merged_mode = if same_provenance
&& (existing.mode.permits_bind() || cap.mode.permits_bind())
{
UnixSocketMode::ConnectBind
} else {
UnixSocketMode::Connect
};
let keep_new = match (new_is_user, existing_is_user) {
(true, false) => true,
(false, true) => false,
_ => cap.mode.permits_bind() && !existing.mode.permits_bind(),
};
if keep_new {
to_remove.push(existing_idx);
seen.insert(key, i);
if cap.original == cap.resolved && existing.original != existing.resolved {
original_updates.push((i, existing.original.clone()));
}
if same_provenance && merged_mode != cap.mode {
mode_upgrades.push((i, merged_mode));
}
} else {
if existing.original == existing.resolved && cap.original != cap.resolved {
original_updates.push((existing_idx, cap.original.clone()));
}
to_remove.push(i);
if same_provenance && merged_mode != existing.mode {
mode_upgrades.push((existing_idx, merged_mode));
}
}
} else {
seen.insert(key, i);
}
}
for (idx, original) in original_updates {
self.unix_sockets[idx].original = original;
}
for (idx, mode) in mode_upgrades {
self.unix_sockets[idx].mode = mode;
}
to_remove.sort_unstable();
to_remove.reverse();
for idx in to_remove {
self.unix_sockets.remove(idx);
}
}
#[must_use]
pub fn path_covered(&self, path: &Path) -> bool {
self.fs
.iter()
.any(|cap| !cap.is_file && path.starts_with(&cap.resolved))
}
#[must_use]
pub fn path_covered_with_access(&self, path: &Path, required: AccessMode) -> bool {
self.fs.iter().any(|cap| {
!cap.is_file && path.starts_with(&cap.resolved) && cap.access.contains(required)
})
}
#[must_use]
pub fn summary(&self) -> String {
let mut lines = Vec::new();
if !self.fs.is_empty() {
lines.push("Filesystem:".to_string());
for cap in &self.fs {
let kind = if cap.is_file { "file" } else { "dir" };
lines.push(format!(
" {} [{}] ({})",
cap.resolved.display(),
cap.access,
kind
));
}
}
if !self.unix_sockets.is_empty() {
lines.push("Unix sockets:".to_string());
for cap in &self.unix_sockets {
let scope = if cap.is_directory { "dir" } else { "file" };
lines.push(format!(
" {} [{}] ({})",
cap.resolved.display(),
cap.mode,
scope
));
}
}
if lines.is_empty() {
lines.push("(no capabilities granted)".to_string());
}
lines.push("Network:".to_string());
lines.push(format!(" outbound: {}", self.network_mode));
if !self.tcp_connect_ports.is_empty() {
let ports: Vec<String> = self
.tcp_connect_ports
.iter()
.map(|p| p.to_string())
.collect();
lines.push(format!(" tcp connect ports: {}", ports.join(", ")));
}
if !self.tcp_bind_ports.is_empty() {
let ports: Vec<String> = self.tcp_bind_ports.iter().map(|p| p.to_string()).collect();
lines.push(format!(" tcp bind ports: {}", ports.join(", ")));
}
lines.join("\n")
}
}
fn rewrite_procfs_self_reference(
original: &Path,
process_pid: u32,
thread_pid: Option<u32>,
) -> Option<PathBuf> {
let thread_pid = thread_pid.unwrap_or(process_pid);
match original {
path if path == Path::new("/dev/fd") => {
return Some(PathBuf::from(format!("/proc/{process_pid}/fd")));
}
path if path == Path::new("/dev/stdin") => {
return Some(PathBuf::from(format!("/proc/{process_pid}/fd/0")));
}
path if path == Path::new("/dev/stdout") => {
return Some(PathBuf::from(format!("/proc/{process_pid}/fd/1")));
}
path if path == Path::new("/dev/stderr") => {
return Some(PathBuf::from(format!("/proc/{process_pid}/fd/2")));
}
_ => {}
}
let mut components = original.components();
if components.next() != Some(Component::RootDir)
|| components.next() != Some(Component::Normal(std::ffi::OsStr::new("proc")))
{
return None;
}
let proc_component = components.next()?;
let mut rewritten = PathBuf::from("/proc");
match proc_component {
Component::Normal(part) if part == std::ffi::OsStr::new("self") => {
rewritten.push(process_pid.to_string());
}
Component::Normal(part) if part == std::ffi::OsStr::new("thread-self") => {
rewritten.push(process_pid.to_string());
rewritten.push("task");
rewritten.push(thread_pid.to_string());
}
_ => return None,
}
for component in components {
match component {
Component::Normal(part) => rewritten.push(part),
Component::CurDir => rewritten.push("."),
Component::ParentDir => rewritten.push(".."),
Component::RootDir | Component::Prefix(_) => {}
}
}
Some(rewritten)
}
#[cfg(test)]
mod procfs_remap_tests {
use super::*;
#[test]
fn remap_procfs_self_rewrites_proc_self_capability() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/proc/self"),
resolved: PathBuf::from("/proc/111/self-was-parent"),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::Group("system_read_linux".to_string()),
});
caps.remap_procfs_self_references(4242, None);
assert_eq!(
caps.fs_capabilities()[0].original,
PathBuf::from("/proc/self")
);
assert_eq!(
caps.fs_capabilities()[0].resolved,
PathBuf::from("/proc/4242")
);
}
#[test]
fn remap_procfs_self_rewrites_dev_fd_aliases() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/dev/fd"),
resolved: PathBuf::from("/proc/111/fd"),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::Group("system_read_linux".to_string()),
});
caps.add_fs(FsCapability {
original: PathBuf::from("/dev/stdout"),
resolved: PathBuf::from("/proc/111/fd/1"),
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Group("system_read_linux".to_string()),
});
caps.remap_procfs_self_references(4242, None);
assert_eq!(
caps.fs_capabilities()[0].resolved,
PathBuf::from("/proc/4242/fd")
);
assert_eq!(
caps.fs_capabilities()[1].resolved,
PathBuf::from("/proc/4242/fd/1")
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_fs_capability_new_dir() {
let dir = tempdir().unwrap();
let path = dir.path();
let cap = FsCapability::new_dir(path, AccessMode::Read).unwrap();
assert_eq!(cap.access, AccessMode::Read);
assert!(cap.resolved.is_absolute());
assert!(!cap.is_file);
}
#[test]
fn test_fs_capability_new_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.txt");
fs::write(&file_path, "test").unwrap();
let cap = FsCapability::new_file(&file_path, AccessMode::Read).unwrap();
assert_eq!(cap.access, AccessMode::Read);
assert!(cap.resolved.is_absolute());
assert!(cap.is_file);
}
#[test]
fn test_fs_capability_nonexistent() {
let result = FsCapability::new_dir("/nonexistent/path/12345", AccessMode::Read);
assert!(matches!(result, Err(NonoError::PathNotFound(_))));
}
#[test]
fn test_fs_capability_file_as_dir_error() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.txt");
fs::write(&file_path, "test").unwrap();
let result = FsCapability::new_dir(&file_path, AccessMode::Read);
assert!(matches!(result, Err(NonoError::ExpectedDirectory(_))));
}
#[test]
fn test_fs_capability_dir_as_file_error() {
let dir = tempdir().unwrap();
let path = dir.path();
let result = FsCapability::new_file(path, AccessMode::Read);
assert!(matches!(result, Err(NonoError::ExpectedFile(_))));
}
#[test]
fn test_capability_set_builder() {
let dir = tempdir().unwrap();
let caps = CapabilitySet::new()
.allow_path(dir.path(), AccessMode::ReadWrite)
.unwrap()
.block_network()
.allow_command("allowed_cmd")
.block_command("blocked_cmd");
assert_eq!(caps.fs_capabilities().len(), 1);
assert!(caps.is_network_blocked());
assert_eq!(caps.allowed_commands(), &["allowed_cmd"]);
assert_eq!(caps.blocked_commands(), &["blocked_cmd"]);
}
#[test]
fn test_capability_set_deduplicate() {
let dir = tempdir().unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(dir.path(), AccessMode::Read).unwrap());
caps.add_fs(FsCapability::new_dir(dir.path(), AccessMode::ReadWrite).unwrap());
assert_eq!(caps.fs_capabilities().len(), 2);
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
assert_eq!(caps.fs_capabilities()[0].access, AccessMode::ReadWrite);
}
#[test]
fn test_deduplicate_user_wins_over_system() {
let path = PathBuf::from("/some/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::User,
});
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::System,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::Read);
assert!(matches!(surviving.source, CapabilitySource::User));
}
#[test]
fn test_deduplicate_user_wins_over_system_reverse_order() {
let path = PathBuf::from("/some/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::System,
});
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::User,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::Read);
assert!(matches!(surviving.source, CapabilitySource::User));
}
#[test]
fn test_deduplicate_merges_read_and_write_to_readwrite() {
let path = PathBuf::from("/some/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::System,
});
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Write,
is_file: false,
source: CapabilitySource::System,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::ReadWrite);
}
#[test]
fn test_deduplicate_merges_write_then_read_to_readwrite() {
let path = PathBuf::from("/some/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Write,
is_file: false,
source: CapabilitySource::System,
});
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::System,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::ReadWrite);
}
#[test]
fn test_deduplicate_preserves_symlink_original() {
let symlink_path = PathBuf::from("/symlink/path");
let real_path = PathBuf::from("/real/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: symlink_path.clone(),
resolved: real_path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::User,
});
caps.add_fs(FsCapability {
original: real_path.clone(),
resolved: real_path.clone(),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::System,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::Read);
assert!(matches!(surviving.source, CapabilitySource::User));
assert_eq!(surviving.original, symlink_path);
assert_eq!(surviving.resolved, real_path);
}
#[test]
fn test_deduplicate_preserves_symlink_original_keep_existing() {
let symlink_path = PathBuf::from("/symlink/path");
let real_path = PathBuf::from("/real/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: real_path.clone(),
resolved: real_path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::System,
});
caps.add_fs(FsCapability {
original: symlink_path.clone(),
resolved: real_path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::User,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.original, symlink_path);
assert_eq!(surviving.resolved, real_path);
}
#[test]
fn test_deduplicate_user_upgrades_group_read_to_readwrite() {
let path = PathBuf::from("/some/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::Group("node_runtime".to_string()),
});
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::User,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::ReadWrite);
assert!(matches!(surviving.source, CapabilitySource::User));
}
#[test]
fn test_deduplicate_user_write_merges_with_group_read() {
let path = PathBuf::from("/some/path");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::Group("node_runtime".to_string()),
});
caps.add_fs(FsCapability {
original: path.clone(),
resolved: path.clone(),
access: AccessMode::Write,
is_file: false,
source: CapabilitySource::User,
});
caps.deduplicate();
assert_eq!(caps.fs_capabilities().len(), 1);
let surviving = &caps.fs_capabilities()[0];
assert_eq!(surviving.access, AccessMode::ReadWrite);
assert!(matches!(surviving.source, CapabilitySource::User));
}
#[cfg(unix)]
#[test]
fn test_fs_capability_symlink_resolution() {
let dir = tempdir().unwrap();
let real_dir = dir.path().join("real");
let symlink = dir.path().join("link");
fs::create_dir(&real_dir).unwrap();
std::os::unix::fs::symlink(&real_dir, &symlink).unwrap();
let cap = FsCapability::new_dir(&symlink, AccessMode::Read).unwrap();
assert_eq!(cap.resolved, real_dir.canonicalize().unwrap());
}
#[test]
fn test_extensions_flag() {
let caps = CapabilitySet::new();
assert!(!caps.extensions_enabled());
let caps = caps.enable_extensions();
assert!(caps.extensions_enabled());
}
#[test]
fn test_extensions_flag_mutable() {
let mut caps = CapabilitySet::new();
assert!(!caps.extensions_enabled());
caps.set_extensions_enabled(true);
assert!(caps.extensions_enabled());
caps.set_extensions_enabled(false);
assert!(!caps.extensions_enabled());
}
#[test]
fn test_platform_rule_validation_valid_deny() {
let mut caps = CapabilitySet::new();
assert!(caps.add_platform_rule("(deny file-write-unlink)").is_ok());
assert!(caps
.add_platform_rule("(deny file-read-data (subpath \"/secret\"))")
.is_ok());
}
#[test]
fn test_platform_rule_validation_rejects_malformed() {
let mut caps = CapabilitySet::new();
assert!(caps.add_platform_rule("not an s-expression").is_err());
assert!(caps.add_platform_rule("").is_err());
}
#[test]
fn test_platform_rule_validation_rejects_root_access() {
let mut caps = CapabilitySet::new();
assert!(caps
.add_platform_rule("(allow file-read* (subpath \"/\"))")
.is_err());
assert!(caps
.add_platform_rule("(allow file-write* (subpath \"/\"))")
.is_err());
assert!(caps
.add_platform_rule("(allow file-read* (subpath \"/usr\"))")
.is_ok());
}
#[test]
fn test_platform_rule_validation_rejects_whitespace_bypass() {
let mut caps = CapabilitySet::new();
assert!(caps
.add_platform_rule("(allow\tfile-read*\t(subpath\t\"/\"))")
.is_err());
assert!(caps
.add_platform_rule("(allow file-read* (subpath \"/\"))")
.is_err());
assert!(caps
.add_platform_rule("(allow \t file-write* \t (subpath \"/\"))")
.is_err());
}
#[test]
fn test_platform_rule_validation_rejects_comment_bypass() {
let mut caps = CapabilitySet::new();
assert!(caps
.add_platform_rule("(allow file-read* #| comment |# (subpath \"/\"))")
.is_err());
assert!(caps
.add_platform_rule("(allow #| sneaky |# file-write* (subpath \"/\"))")
.is_err());
}
#[test]
fn test_platform_rule_validation_rejects_unbalanced_parens() {
let mut caps = CapabilitySet::new();
assert!(caps.add_platform_rule("(deny file-read*").is_err());
assert!(caps.add_platform_rule("(deny file-read*))").is_err());
}
#[test]
fn test_platform_rule_validation_rejects_unterminated_constructs() {
let mut caps = CapabilitySet::new();
assert!(caps
.add_platform_rule("(deny file-read* #| unterminated comment")
.is_err());
assert!(caps
.add_platform_rule("(deny file-read* (subpath \"/usr))")
.is_err());
}
#[test]
fn test_platform_rule_validation_accepts_gpu_iokit_rules() {
let mut caps = CapabilitySet::new();
assert!(caps
.add_platform_rule(
"(allow iokit-open \
(iokit-user-client-class \
\"AGXDeviceUserClient\"))"
)
.is_ok());
assert!(caps
.add_platform_rule("(allow iokit-get-properties)")
.is_ok());
assert_eq!(caps.platform_rules().len(), 2);
}
#[test]
fn test_network_mode_default_is_allow_all() {
let caps = CapabilitySet::new();
assert_eq!(*caps.network_mode(), NetworkMode::AllowAll);
assert!(!caps.is_network_blocked());
}
#[test]
fn test_block_network_sets_blocked_mode() {
let caps = CapabilitySet::new().block_network();
assert_eq!(*caps.network_mode(), NetworkMode::Blocked);
assert!(caps.is_network_blocked());
}
#[test]
fn test_proxy_only_mode() {
let caps = CapabilitySet::new().proxy_only(8080);
assert_eq!(
*caps.network_mode(),
NetworkMode::ProxyOnly {
port: 8080,
bind_ports: vec![]
}
);
assert!(caps.is_network_blocked());
}
#[test]
fn test_proxy_only_with_bind_ports() {
let caps = CapabilitySet::new().proxy_only_with_bind(8080, vec![18789, 3000]);
assert_eq!(
*caps.network_mode(),
NetworkMode::ProxyOnly {
port: 8080,
bind_ports: vec![18789, 3000]
}
);
assert!(caps.is_network_blocked());
}
#[test]
fn test_set_network_mode_builder() {
let caps = CapabilitySet::new().set_network_mode(NetworkMode::ProxyOnly {
port: 54321,
bind_ports: vec![],
});
assert_eq!(
*caps.network_mode(),
NetworkMode::ProxyOnly {
port: 54321,
bind_ports: vec![]
}
);
}
#[test]
fn test_set_network_blocked_backward_compat() {
let mut caps = CapabilitySet::new();
caps.set_network_blocked(true);
assert_eq!(*caps.network_mode(), NetworkMode::Blocked);
assert!(caps.is_network_blocked());
caps.set_network_blocked(false);
assert_eq!(*caps.network_mode(), NetworkMode::AllowAll);
assert!(!caps.is_network_blocked());
}
#[test]
fn test_tcp_connect_ports() {
let caps = CapabilitySet::new()
.allow_tcp_connect(443)
.allow_tcp_connect(8443);
assert_eq!(caps.tcp_connect_ports(), &[443, 8443]);
}
#[test]
fn test_tcp_bind_ports() {
let caps = CapabilitySet::new()
.allow_tcp_bind(8080)
.allow_tcp_bind(3000);
assert_eq!(caps.tcp_bind_ports(), &[8080, 3000]);
}
#[test]
fn test_allow_https_convenience() {
let caps = CapabilitySet::new().allow_https();
assert_eq!(caps.tcp_connect_ports(), &[443, 8443]);
}
#[test]
fn test_tcp_ports_mutable() {
let mut caps = CapabilitySet::new();
caps.add_tcp_connect_port(443);
caps.add_tcp_bind_port(8080);
assert_eq!(caps.tcp_connect_ports(), &[443]);
assert_eq!(caps.tcp_bind_ports(), &[8080]);
}
#[test]
fn test_localhost_port_builder() {
let caps = CapabilitySet::new()
.allow_localhost_port(3000)
.allow_localhost_port(5000);
assert_eq!(caps.localhost_ports(), &[3000, 5000]);
}
#[test]
fn test_localhost_port_mutable() {
let mut caps = CapabilitySet::new();
caps.add_localhost_port(8080);
caps.add_localhost_port(9090);
assert_eq!(caps.localhost_ports(), &[8080, 9090]);
}
#[test]
fn test_network_mode_display() {
assert_eq!(format!("{}", NetworkMode::Blocked), "blocked");
assert_eq!(format!("{}", NetworkMode::AllowAll), "allowed");
assert_eq!(
format!(
"{}",
NetworkMode::ProxyOnly {
port: 8080,
bind_ports: vec![]
}
),
"proxy-only (localhost:8080)"
);
assert_eq!(
format!(
"{}",
NetworkMode::ProxyOnly {
port: 8080,
bind_ports: vec![18789]
}
),
"proxy-only (localhost:8080, bind: 18789)"
);
assert_eq!(
format!(
"{}",
NetworkMode::ProxyOnly {
port: 8080,
bind_ports: vec![18789, 3000]
}
),
"proxy-only (localhost:8080, bind: 18789, 3000)"
);
}
#[test]
fn test_network_mode_serialization() {
let mode = NetworkMode::ProxyOnly {
port: 54321,
bind_ports: vec![],
};
let json = serde_json::to_string(&mode).unwrap();
let deserialized: NetworkMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, deserialized);
}
#[test]
fn test_network_mode_serialization_with_bind_ports() {
let mode = NetworkMode::ProxyOnly {
port: 54321,
bind_ports: vec![18789, 3000],
};
let json = serde_json::to_string(&mode).unwrap();
let deserialized: NetworkMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, deserialized);
}
#[test]
fn test_summary_includes_network_mode() {
let caps = CapabilitySet::new().proxy_only(8080);
let summary = caps.summary();
assert!(summary.contains("proxy-only (localhost:8080)"));
}
#[test]
fn test_summary_includes_tcp_ports() {
let caps = CapabilitySet::new()
.allow_tcp_connect(443)
.allow_tcp_bind(8080);
let summary = caps.summary();
assert!(summary.contains("tcp connect ports: 443"));
assert!(summary.contains("tcp bind ports: 8080"));
}
#[test]
fn test_signal_mode_allow_same_sandbox_roundtrip() {
let caps = CapabilitySet::new().set_signal_mode(SignalMode::AllowSameSandbox);
assert_eq!(caps.signal_mode(), SignalMode::AllowSameSandbox);
}
#[test]
fn test_process_info_mode_default_is_isolated() {
let caps = CapabilitySet::new();
assert_eq!(caps.process_info_mode(), ProcessInfoMode::Isolated);
}
#[test]
fn test_process_info_mode_allow_same_sandbox() {
let caps = CapabilitySet::new().set_process_info_mode(ProcessInfoMode::AllowSameSandbox);
assert_eq!(caps.process_info_mode(), ProcessInfoMode::AllowSameSandbox);
}
#[test]
fn test_process_info_mode_allow_all() {
let caps = CapabilitySet::new().set_process_info_mode(ProcessInfoMode::AllowAll);
assert_eq!(caps.process_info_mode(), ProcessInfoMode::AllowAll);
}
#[test]
fn test_ipc_mode_default_is_shared_memory_only() {
let caps = CapabilitySet::new();
assert_eq!(caps.ipc_mode(), IpcMode::SharedMemoryOnly);
}
#[test]
fn test_ipc_mode_full() {
let caps = CapabilitySet::new().set_ipc_mode(IpcMode::Full);
assert_eq!(caps.ipc_mode(), IpcMode::Full);
}
#[test]
fn test_ipc_mode_mutable_setter() {
let mut caps = CapabilitySet::new();
assert_eq!(caps.ipc_mode(), IpcMode::SharedMemoryOnly);
caps.set_ipc_mode_mut(IpcMode::Full);
assert_eq!(caps.ipc_mode(), IpcMode::Full);
}
#[test]
fn test_access_mode_contains() {
assert!(AccessMode::ReadWrite.contains(AccessMode::Read));
assert!(AccessMode::ReadWrite.contains(AccessMode::Write));
assert!(AccessMode::ReadWrite.contains(AccessMode::ReadWrite));
assert!(AccessMode::Read.contains(AccessMode::Read));
assert!(!AccessMode::Read.contains(AccessMode::Write));
assert!(!AccessMode::Read.contains(AccessMode::ReadWrite));
assert!(AccessMode::Write.contains(AccessMode::Write));
assert!(!AccessMode::Write.contains(AccessMode::Read));
assert!(!AccessMode::Write.contains(AccessMode::ReadWrite));
}
#[test]
fn test_path_covered_basic() {
let dir = tempdir().unwrap();
let parent = dir.path();
let child = parent.join("subdir");
fs::create_dir(&child).unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(parent, AccessMode::Read).unwrap());
assert!(caps.path_covered(&child.canonicalize().unwrap()));
}
#[test]
fn test_path_covered_not_matching() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(dir1.path(), AccessMode::Read).unwrap());
assert!(!caps.path_covered(&dir2.path().canonicalize().unwrap()));
}
#[test]
fn test_path_covered_with_access_read_parent_does_not_satisfy_readwrite() {
let dir = tempdir().unwrap();
let parent = dir.path();
let child = parent.join("project");
fs::create_dir(&child).unwrap();
let child_canonical = child.canonicalize().unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(parent, AccessMode::Read).unwrap());
assert!(caps.path_covered(&child_canonical));
assert!(caps.path_covered_with_access(&child_canonical, AccessMode::Read));
assert!(!caps.path_covered_with_access(&child_canonical, AccessMode::Write));
assert!(!caps.path_covered_with_access(&child_canonical, AccessMode::ReadWrite));
}
#[test]
fn test_path_covered_with_access_readwrite_parent_satisfies_all() {
let dir = tempdir().unwrap();
let parent = dir.path();
let child = parent.join("project");
fs::create_dir(&child).unwrap();
let child_canonical = child.canonicalize().unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(parent, AccessMode::ReadWrite).unwrap());
assert!(caps.path_covered_with_access(&child_canonical, AccessMode::Read));
assert!(caps.path_covered_with_access(&child_canonical, AccessMode::Write));
assert!(caps.path_covered_with_access(&child_canonical, AccessMode::ReadWrite));
}
#[test]
fn test_path_covered_with_access_file_caps_ignored() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("file.txt");
fs::write(&file_path, "data").unwrap();
let file_canonical = file_path.canonicalize().unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_file(&file_path, AccessMode::ReadWrite).unwrap());
assert!(!caps.path_covered_with_access(&file_canonical, AccessMode::Read));
}
#[test]
fn test_remove_exact_file_caps_for_paths_matches_original_and_resolved() {
let dir = tempdir().unwrap();
let target = dir.path().join("target.txt");
fs::write(&target, "secret").unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_file(&link, AccessMode::Read).unwrap());
caps.add_fs(FsCapability::new_dir(dir.path(), AccessMode::Read).unwrap());
let removed = caps.remove_exact_file_caps_for_paths(&[link.clone(), target.clone()]);
assert_eq!(removed, 1);
assert_eq!(caps.fs_capabilities().len(), 1);
assert!(!caps.fs_capabilities()[0].is_file);
}
#[test]
fn test_unix_socket_mode_permits_bind() {
assert!(!UnixSocketMode::Connect.permits_bind());
assert!(UnixSocketMode::ConnectBind.permits_bind());
}
#[test]
fn test_unix_socket_connect_requires_existing_path() {
let dir = tempdir().unwrap();
let missing = dir.path().join("ghost.sock");
let result = UnixSocketCapability::new_file(&missing, UnixSocketMode::Connect);
assert!(
matches!(result, Err(NonoError::PathNotFound(_))),
"connect grant on non-existent path must fail: {result:?}"
);
}
#[test]
fn test_unix_socket_connect_on_existing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("existing.sock");
fs::write(&path, b"").unwrap();
let cap = UnixSocketCapability::new_file(&path, UnixSocketMode::Connect).unwrap();
assert_eq!(cap.mode, UnixSocketMode::Connect);
assert!(!cap.is_directory);
assert!(cap.resolved.is_absolute());
}
#[test]
fn test_unix_socket_connect_bind_allows_nonexistent_path() {
let dir = tempdir().unwrap();
let missing = dir.path().join("pending.sock");
let cap = UnixSocketCapability::new_file(&missing, UnixSocketMode::ConnectBind).unwrap();
assert_eq!(cap.mode, UnixSocketMode::ConnectBind);
assert!(!cap.is_directory);
assert_eq!(cap.resolved.file_name().unwrap(), "pending.sock");
assert!(cap.resolved.parent().unwrap().is_absolute());
}
#[test]
fn test_unix_socket_connect_bind_fails_when_parent_missing() {
let result = UnixSocketCapability::new_file(
"/definitely/does/not/exist/12345/x.sock",
UnixSocketMode::ConnectBind,
);
assert!(
matches!(result, Err(NonoError::PathNotFound(_))),
"bind grant must fail when parent is missing: {result:?}"
);
}
#[test]
fn test_unix_socket_file_rejects_directory_path() {
let dir = tempdir().unwrap();
let result = UnixSocketCapability::new_file(dir.path(), UnixSocketMode::Connect);
assert!(
matches!(result, Err(NonoError::ExpectedFile(_))),
"new_file must reject a directory path: {result:?}"
);
}
#[test]
fn test_unix_socket_dir_on_existing_directory() {
let dir = tempdir().unwrap();
let cap = UnixSocketCapability::new_dir(dir.path(), UnixSocketMode::Connect).unwrap();
assert!(cap.is_directory);
assert!(cap.resolved.is_absolute());
}
#[test]
fn test_unix_socket_dir_rejects_file_path() {
let dir = tempdir().unwrap();
let file = dir.path().join("regular.txt");
fs::write(&file, "not a dir").unwrap();
let result = UnixSocketCapability::new_dir(&file, UnixSocketMode::Connect);
assert!(
matches!(result, Err(NonoError::ExpectedDirectory(_))),
"new_dir must reject a file path: {result:?}"
);
}
#[test]
fn test_unix_socket_dir_nonexistent() {
let result = UnixSocketCapability::new_dir(
"/nonexistent/dir/for/tests/99999",
UnixSocketMode::Connect,
);
assert!(matches!(result, Err(NonoError::PathNotFound(_))));
}
#[test]
fn test_unix_socket_dir_rejects_filesystem_root() {
let result = UnixSocketCapability::new_dir("/", UnixSocketMode::Connect);
assert!(
matches!(result, Err(NonoError::SandboxInit(_))),
"filesystem root must be rejected as a directory grant: {result:?}"
);
}
#[test]
fn test_unix_socket_covers_file_exact_match() {
let dir = tempdir().unwrap();
let path = dir.path().join("a.sock");
fs::write(&path, b"").unwrap();
let cap = UnixSocketCapability::new_file(&path, UnixSocketMode::Connect).unwrap();
assert!(cap.covers(&cap.resolved));
assert!(!cap.covers(&dir.path().canonicalize().unwrap()));
let sibling = dir.path().canonicalize().unwrap().join("b.sock");
assert!(!cap.covers(&sibling));
}
#[test]
fn test_unix_socket_covers_directory_one_level() {
let dir = tempdir().unwrap();
let cap = UnixSocketCapability::new_dir(dir.path(), UnixSocketMode::Connect).unwrap();
let child = cap.resolved.join("x.sock");
assert!(cap.covers(&child), "direct child should be covered");
let grandchild = cap.resolved.join("sub").join("x.sock");
assert!(!cap.covers(&grandchild), "grandchild must not be covered");
assert!(!cap.covers(&cap.resolved));
}
#[test]
fn test_unix_socket_covers_does_not_string_prefix() {
let dir = tempdir().unwrap();
let foo = dir.path().join("foo");
let foobar = dir.path().join("foobar");
fs::create_dir(&foo).unwrap();
fs::create_dir(&foobar).unwrap();
let cap = UnixSocketCapability::new_dir(&foo, UnixSocketMode::Connect).unwrap();
let evil = foobar.canonicalize().unwrap().join("x.sock");
assert!(!cap.covers(&evil), "string-prefix match must not leak");
}
#[test]
fn test_unix_socket_display() {
let dir = tempdir().unwrap();
let path = dir.path().join("a.sock");
fs::write(&path, b"").unwrap();
let file_cap = UnixSocketCapability::new_file(&path, UnixSocketMode::Connect).unwrap();
let rendered = format!("{file_cap}");
assert!(rendered.contains("connect"));
assert!(!rendered.starts_with("dir"));
let dir_cap =
UnixSocketCapability::new_dir(dir.path(), UnixSocketMode::ConnectBind).unwrap();
let rendered = format!("{dir_cap}");
assert!(rendered.contains("connect+bind"));
assert!(rendered.starts_with("dir "));
}
#[test]
fn test_capability_set_allow_unix_socket_accumulates() {
let dir = tempdir().unwrap();
let a = dir.path().join("a.sock");
let b = dir.path().join("b.sock");
fs::write(&a, b"").unwrap();
let caps = CapabilitySet::new()
.allow_unix_socket(&a, UnixSocketMode::Connect)
.unwrap()
.allow_unix_socket(&b, UnixSocketMode::ConnectBind)
.unwrap();
assert_eq!(caps.unix_socket_capabilities().len(), 2);
assert_eq!(
caps.unix_socket_capabilities()[0].mode,
UnixSocketMode::Connect
);
assert_eq!(
caps.unix_socket_capabilities()[1].mode,
UnixSocketMode::ConnectBind
);
}
#[test]
fn test_capability_set_unix_socket_allowed_mode_split() {
let dir = tempdir().unwrap();
let connect_sock = dir.path().join("connect-only.sock");
let bind_sock = dir.path().join("bind.sock");
fs::write(&connect_sock, b"").unwrap();
let caps = CapabilitySet::new()
.allow_unix_socket(&connect_sock, UnixSocketMode::Connect)
.unwrap()
.allow_unix_socket(&bind_sock, UnixSocketMode::ConnectBind)
.unwrap();
let resolved_connect = connect_sock.canonicalize().unwrap();
let resolved_bind = dir.path().canonicalize().unwrap().join("bind.sock");
assert!(caps.unix_socket_allowed(&resolved_connect, UnixSocketOp::Connect));
assert!(!caps.unix_socket_allowed(&resolved_connect, UnixSocketOp::Bind));
assert!(caps.unix_socket_allowed(&resolved_bind, UnixSocketOp::Connect));
assert!(caps.unix_socket_allowed(&resolved_bind, UnixSocketOp::Bind));
let other = dir.path().canonicalize().unwrap().join("other.sock");
assert!(!caps.unix_socket_allowed(&other, UnixSocketOp::Connect));
assert!(!caps.unix_socket_allowed(&other, UnixSocketOp::Bind));
}
#[test]
fn test_capability_set_unix_socket_allowed_directory_grant() {
let dir = tempdir().unwrap();
let caps = CapabilitySet::new()
.allow_unix_socket_dir(dir.path(), UnixSocketMode::Connect)
.unwrap();
let resolved_dir = dir.path().canonicalize().unwrap();
let direct_child = resolved_dir.join("x.sock");
let grandchild = resolved_dir.join("sub").join("x.sock");
assert!(caps.unix_socket_allowed(&direct_child, UnixSocketOp::Connect));
assert!(!caps.unix_socket_allowed(&grandchild, UnixSocketOp::Connect));
assert!(!caps.unix_socket_allowed(&direct_child, UnixSocketOp::Bind));
}
#[test]
fn test_deduplicate_unix_sockets_merges_identical_grants() {
let dir = tempdir().unwrap();
let sock = dir.path().join("a.sock");
fs::write(&sock, b"").unwrap();
let mut caps = CapabilitySet::new()
.allow_unix_socket(&sock, UnixSocketMode::Connect)
.unwrap()
.allow_unix_socket(&sock, UnixSocketMode::Connect)
.unwrap();
assert_eq!(caps.unix_socket_capabilities().len(), 2);
caps.deduplicate();
assert_eq!(caps.unix_socket_capabilities().len(), 1);
}
#[test]
fn test_deduplicate_unix_sockets_promotes_connect_to_connect_bind() {
let dir = tempdir().unwrap();
let sock = dir.path().join("a.sock");
fs::write(&sock, b"").unwrap();
let mut caps = CapabilitySet::new()
.allow_unix_socket(&sock, UnixSocketMode::Connect)
.unwrap()
.allow_unix_socket(&sock, UnixSocketMode::ConnectBind)
.unwrap();
caps.deduplicate();
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
}
#[test]
fn test_deduplicate_unix_sockets_does_not_widen_user_intent() {
let dir = tempdir().unwrap();
let sock = dir.path().join("a.sock");
fs::write(&sock, b"").unwrap();
let group_cap = UnixSocketCapability {
original: sock.clone(),
resolved: sock.canonicalize().unwrap(),
is_directory: false,
mode: UnixSocketMode::ConnectBind,
source: CapabilitySource::Group("example_group".to_string()),
};
let user_cap = UnixSocketCapability {
original: sock.clone(),
resolved: sock.canonicalize().unwrap(),
is_directory: false,
mode: UnixSocketMode::Connect,
source: CapabilitySource::User,
};
let mut caps = CapabilitySet::new();
caps.add_unix_socket(group_cap);
caps.add_unix_socket(user_cap);
caps.deduplicate();
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(
socks[0].mode,
UnixSocketMode::Connect,
"user-intent Connect must not be upgraded to ConnectBind by dedup"
);
assert!(matches!(socks[0].source, CapabilitySource::User));
}
#[test]
fn test_deduplicate_unix_sockets_keeps_file_and_dir_grants_separate() {
let dir = tempdir().unwrap();
let sock = dir.path().join("a.sock");
fs::write(&sock, b"").unwrap();
let mut caps = CapabilitySet::new()
.allow_unix_socket(&sock, UnixSocketMode::Connect)
.unwrap()
.allow_unix_socket_dir(dir.path(), UnixSocketMode::Connect)
.unwrap();
caps.deduplicate();
assert_eq!(caps.unix_socket_capabilities().len(), 2);
}
#[test]
fn test_summary_includes_unix_sockets() {
let dir = tempdir().unwrap();
let sock = dir.path().join("a.sock");
fs::write(&sock, b"").unwrap();
let caps = CapabilitySet::new()
.allow_unix_socket(&sock, UnixSocketMode::Connect)
.unwrap()
.allow_unix_socket_dir(dir.path(), UnixSocketMode::ConnectBind)
.unwrap();
let summary = caps.summary();
assert!(
summary.contains("Unix sockets:"),
"summary must include unix socket section: {summary}"
);
assert!(summary.contains("connect"));
assert!(summary.contains("connect+bind"));
assert!(summary.contains("file"));
assert!(summary.contains("dir"));
}
}