use crate::package;
use crate::profile;
use nono::{AccessMode, CapabilitySet, CapabilitySource, FsCapability, NonoError, Result};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Deserialize)]
pub struct Policy {
#[allow(dead_code)]
pub meta: PolicyMeta,
pub groups: HashMap<String, Group>,
#[serde(default)]
pub profiles: HashMap<String, ProfileDef>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyMeta {
#[allow(dead_code)]
pub version: u64,
#[allow(dead_code)]
pub schema_version: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Group {
#[allow(dead_code)]
pub description: String,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub allow: Option<AllowOps>,
#[serde(default)]
pub deny: Option<DenyOps>,
#[serde(default)]
pub symlink_pairs: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AllowOps {
#[serde(default)]
pub read: Vec<String>,
#[serde(default)]
pub write: Vec<String>,
#[serde(default)]
pub readwrite: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DenyOps {
#[serde(default)]
pub access: Vec<String>,
#[serde(default)]
pub unlink: bool,
#[serde(default)]
pub unlink_override_for_user_writable: bool,
#[serde(default)]
pub commands: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProfileDef {
#[serde(default)]
pub extends: Option<String>,
#[serde(default)]
pub meta: profile::ProfileMeta,
#[serde(default)]
pub security: profile::SecurityConfig,
#[serde(default)]
pub exclude_groups: Vec<String>,
#[serde(default)]
pub filesystem: profile::FilesystemConfig,
#[serde(default)]
pub policy: profile::PolicyPatchConfig,
#[serde(default)]
pub network: profile::NetworkConfig,
#[serde(default, alias = "secrets")]
pub env_credentials: profile::SecretsConfig,
#[serde(default)]
pub workdir: profile::WorkdirConfig,
#[serde(default)]
pub hooks: profile::HooksConfig,
#[serde(default, alias = "undo")]
pub rollback: profile::RollbackConfig,
#[serde(default)]
pub open_urls: Option<profile::OpenUrlConfig>,
#[serde(default)]
pub allow_launch_services: Option<bool>,
#[serde(default)]
pub allow_gpu: Option<bool>,
#[serde(default)]
pub interactive: bool,
#[serde(default)]
pub packs: Vec<String>,
#[serde(default)]
pub command_args: Vec<String>,
#[serde(default)]
pub unsafe_macos_seatbelt_rules: Vec<String>,
}
impl ProfileDef {
pub fn to_raw_profile(&self) -> profile::Profile {
let mut policy = self.policy.clone();
policy.exclude_groups =
profile::dedup_append(&self.exclude_groups, &self.policy.exclude_groups);
profile::Profile {
extends: self.extends.as_ref().map(|s| vec![s.clone()]),
meta: self.meta.clone(),
security: profile::SecurityConfig {
groups: self.security.groups.clone(),
allowed_commands: self.security.allowed_commands.clone(),
signal_mode: self.security.signal_mode,
process_info_mode: self.security.process_info_mode,
ipc_mode: self.security.ipc_mode,
capability_elevation: self.security.capability_elevation,
wsl2_proxy_policy: self.security.wsl2_proxy_policy,
},
filesystem: self.filesystem.clone(),
policy,
network: self.network.clone(),
env_credentials: self.env_credentials.clone(),
environment: None,
workdir: self.workdir.clone(),
hooks: self.hooks.clone(),
rollback: self.rollback.clone(),
open_urls: self.open_urls.clone(),
allow_launch_services: self.allow_launch_services,
allow_gpu: self.allow_gpu,
allow_parent_of_protected: None,
interactive: self.interactive,
skipdirs: Vec::new(),
packs: self.packs.clone(),
command_args: self.command_args.clone(),
unsafe_macos_seatbelt_rules: self.unsafe_macos_seatbelt_rules.clone(),
}
}
}
pub(crate) fn current_platform() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
pub(crate) fn group_matches_platform(group: &Group) -> bool {
match &group.platform {
Some(platform) => platform == current_platform(),
None => true, }
}
pub(crate) fn expand_path(path_str: &str) -> Result<PathBuf> {
use crate::config;
let expanded = if let Some(rest) = path_str.strip_prefix("~/") {
let home = config::validated_home()?;
format!("{}/{}", home, rest)
} else if path_str == "~" || path_str == "$HOME" {
config::validated_home()?
} else if let Some(rest) = path_str.strip_prefix("$HOME/") {
let home = config::validated_home()?;
format!("{}/{}", home, rest)
} else if path_str == "$TMPDIR" {
config::validated_tmpdir()?
} else if let Some(rest) = path_str.strip_prefix("$TMPDIR/") {
let tmpdir = config::validated_tmpdir()?;
format!("{}/{}", tmpdir, rest)
} else {
path_str.to_string()
};
Ok(PathBuf::from(expanded))
}
fn is_nix_store_path(path: &Path) -> bool {
path.starts_with("/nix/store")
}
fn should_skip_resolved_deny_target(resolved: &Path) -> bool {
cfg!(target_os = "linux") && is_nix_store_path(resolved)
}
pub(crate) fn path_to_utf8(path: &Path) -> Result<&str> {
path.to_str().ok_or_else(|| {
NonoError::ConfigParse(format!("Path contains non-UTF-8 bytes: {}", path.display()))
})
}
pub(crate) fn escape_seatbelt_path(path: &str) -> Result<String> {
let mut result = String::with_capacity(path.len());
for c in path.chars() {
if c.is_control() {
return Err(NonoError::ConfigParse(format!(
"Path contains control character: {:?}",
path
)));
}
match c {
'\\' => result.push_str("\\\\"),
'"' => result.push_str("\\\""),
_ => result.push(c),
}
}
Ok(result)
}
fn escape_seatbelt_regex_path(path: &str) -> Result<String> {
let mut out = String::with_capacity(path.len() + 8);
for c in path.chars() {
if c.is_control() {
return Err(NonoError::ConfigParse(format!(
"Path contains control character: {:?}",
path
)));
}
match c {
'.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => {
out.push('\\');
out.push(c);
}
'"' => out.push_str("\\\""),
_ => out.push(c),
}
}
Ok(out)
}
pub fn load_policy(json: &str) -> Result<Policy> {
serde_json::from_str(json)
.map_err(|e| NonoError::ConfigParse(format!("Failed to parse policy.json: {}", e)))
}
pub struct ResolvedGroups {
pub names: Vec<String>,
pub needs_unlink_overrides: bool,
pub deny_paths: Vec<PathBuf>,
}
pub fn resolve_groups(
policy: &Policy,
group_names: &[String],
caps: &mut CapabilitySet,
) -> Result<ResolvedGroups> {
let mut resolved_groups = Vec::new();
let mut needs_unlink_overrides = false;
let mut deny_paths = Vec::new();
for name in group_names {
let group = policy
.groups
.get(name.as_str())
.ok_or_else(|| NonoError::ConfigParse(format!("Unknown policy group: '{}'", name)))?;
if !group_matches_platform(group) {
debug!(
"Skipping group '{}' (platform {:?} != {})",
name,
group.platform,
current_platform()
);
continue;
}
if resolve_single_group(name, group, caps, &mut deny_paths)? {
needs_unlink_overrides = true;
}
resolved_groups.push(name.clone());
}
Ok(ResolvedGroups {
names: resolved_groups,
needs_unlink_overrides,
deny_paths,
})
}
fn resolve_single_group(
group_name: &str,
group: &Group,
caps: &mut CapabilitySet,
deny_paths: &mut Vec<PathBuf>,
) -> Result<bool> {
let source = CapabilitySource::Group(group_name.to_string());
let mut needs_unlink_overrides = false;
if let Some(allow) = &group.allow {
for path_str in &allow.read {
add_fs_capability(group_name, path_str, AccessMode::Read, &source, caps)?;
}
for path_str in &allow.write {
add_fs_capability(group_name, path_str, AccessMode::Write, &source, caps)?;
}
for path_str in &allow.readwrite {
add_fs_capability(group_name, path_str, AccessMode::ReadWrite, &source, caps)?;
}
}
if let Some(deny) = &group.deny {
for path_str in &deny.access {
add_deny_access_rules(path_str, caps, deny_paths)?;
}
if deny.unlink && cfg!(target_os = "macos") {
caps.add_platform_rule("(deny file-write-unlink)")?;
}
if deny.unlink_override_for_user_writable {
needs_unlink_overrides = true;
}
for cmd in &deny.commands {
caps.add_blocked_command(cmd.clone());
}
}
if cfg!(target_os = "macos") {
if let Some(pairs) = &group.symlink_pairs {
for symlink in pairs.keys() {
let expanded = expand_path(symlink)?;
let escaped = escape_seatbelt_path(path_to_utf8(&expanded)?)?;
caps.add_platform_rule(format!("(allow file-read* (subpath \"{}\"))", escaped))?;
}
}
}
Ok(needs_unlink_overrides)
}
fn canonicalize_for_comparison(path: &Path) -> PathBuf {
match path.canonicalize() {
Ok(canonical) => canonical,
Err(_) => path.to_path_buf(),
}
}
fn should_skip_group_allow_path(group_name: &str, path: &Path) -> Result<bool> {
if !cfg!(target_os = "linux") || group_name != "system_write_linux" || !path.is_dir() {
return Ok(false);
}
let home = PathBuf::from(crate::config::validated_home()?);
let home_raw_overlaps = home.starts_with(path);
let home_canonical = canonicalize_for_comparison(&home);
let path_canonical = canonicalize_for_comparison(path);
let home_canonical_overlaps = home_canonical.starts_with(&path_canonical);
if !home_raw_overlaps && !home_canonical_overlaps {
return Ok(false);
}
warn!(
"Skipping Linux system temp grant '{}' from group '{}' because HOME '{}' is nested \
inside it. Landlock cannot enforce deny rules beneath an allowed parent.",
path.display(),
group_name,
home.display()
);
Ok(true)
}
fn add_fs_capability(
group_name: &str,
path_str: &str,
mode: AccessMode,
source: &CapabilitySource,
caps: &mut CapabilitySet,
) -> Result<()> {
let path = expand_path(path_str)?;
if !path.exists() {
debug!(
"Group path '{}' (expanded to '{}') does not exist, skipping",
path_str,
path.display()
);
return Ok(());
}
if should_skip_group_allow_path(group_name, &path)? {
return Ok(());
}
if path.is_dir() {
match FsCapability::new_dir(&path, mode) {
Ok(mut cap) => {
cap.source = source.clone();
caps.add_fs(cap);
}
Err(e) => {
debug!("Could not add group directory {}: {}", path_str, e);
}
}
} else {
match FsCapability::new_file(&path, mode) {
Ok(mut cap) => {
cap.source = source.clone();
caps.add_fs(cap);
}
Err(e) => {
debug!("Could not add group file {}: {}", path_str, e);
}
}
}
Ok(())
}
fn resolve_parent_symlinks(path: &Path) -> Result<Option<PathBuf>> {
let mut suffix = Vec::new();
let mut cur = path;
loop {
if cur.exists() {
break;
}
let name = cur.file_name().ok_or_else(|| {
NonoError::ConfigParse(format!(
"cannot resolve parent symlinks for {}",
path.display()
))
})?;
suffix.push(name.to_os_string());
cur = cur.parent().ok_or_else(|| {
NonoError::ConfigParse(format!(
"cannot resolve parent symlinks for {}",
path.display()
))
})?;
}
let mut resolved = cur
.canonicalize()
.map_err(|e| NonoError::ConfigParse(format!("canonicalize {}: {}", cur.display(), e)))?;
for part in suffix.iter().rev() {
resolved.push(part);
}
Ok((resolved != path).then_some(resolved))
}
pub(crate) fn add_deny_access_rules(
path_str: &str,
caps: &mut CapabilitySet,
deny_paths: &mut Vec<PathBuf>,
) -> Result<()> {
let path = expand_path(path_str)?;
deny_paths.push(path.clone());
let canonical = path.canonicalize().ok();
if let Some(ref canonical) = canonical {
if *canonical != path {
if should_skip_resolved_deny_target(canonical) {
debug!(
"Skipping deny canonical path '{}' (Nix store immutable symlink target of '{}')",
canonical.display(),
path.display(),
);
} else {
deny_paths.push(canonical.clone());
}
}
}
let parent_resolved = if canonical.is_none() {
match resolve_parent_symlinks(&path) {
Ok(resolved) => resolved,
Err(e) => {
debug!(
"Skipping parent-symlink resolution for {}: {}",
path.display(),
e
);
None
}
}
} else {
None
};
if let Some(ref resolved) = parent_resolved {
deny_paths.push(resolved.clone());
}
if cfg!(target_os = "macos") {
let emit_deny_rules = |p: &Path, caps: &mut CapabilitySet| -> Result<()> {
let escaped = escape_seatbelt_path(path_to_utf8(p)?)?;
let filter = if p.exists() && p.is_file() {
format!("literal \"{}\"", escaped)
} else {
format!("subpath \"{}\"", escaped)
};
caps.add_platform_rule(format!("(allow file-read-metadata ({}))", filter))?;
caps.add_platform_rule(format!("(deny file-read-data ({}))", filter))?;
caps.add_platform_rule(format!("(deny file-write* ({}))", filter))?;
caps.add_platform_rule(format!("(deny network-outbound (path \"{}\"))", escaped))?;
Ok(())
};
emit_deny_rules(&path, caps)?;
if let Some(ref canonical) = canonical {
if *canonical != path {
if let Err(e) = emit_deny_rules(canonical, caps) {
warn!(
"Skipping canonical deny rules for {}: {}",
canonical.display(),
e
);
}
}
}
if let Some(ref resolved) = parent_resolved {
if let Err(e) = emit_deny_rules(resolved, caps) {
warn!(
"Skipping parent-resolved deny rules for {}: {}",
resolved.display(),
e
);
}
}
}
Ok(())
}
pub fn apply_macos_keychain_db_exception(caps: &mut CapabilitySet) {
if !cfg!(target_os = "macos") {
return;
}
let user_keychain_dbs = std::env::var("HOME").ok().map(|home| {
[
Path::new(&home).join("Library/Keychains/login.keychain-db"),
Path::new(&home).join("Library/Keychains/metadata.keychain-db"),
]
});
let system_keychain_dbs = [
Path::new("/Library/Keychains/login.keychain-db").to_path_buf(),
Path::new("/Library/Keychains/metadata.keychain-db").to_path_buf(),
];
let is_keychain_db = |path: &Path| -> bool {
if system_keychain_dbs
.iter()
.any(|candidate| path == candidate)
{
return true;
}
if let Some(ref user_keychain_dbs) = user_keychain_dbs {
if user_keychain_dbs.iter().any(|candidate| path == candidate) {
return true;
}
}
false
};
let merge_access = |existing: &mut AccessMode, next: AccessMode| {
*existing = match (*existing, next) {
(AccessMode::ReadWrite, _) | (_, AccessMode::ReadWrite) => AccessMode::ReadWrite,
(AccessMode::Read, AccessMode::Write) | (AccessMode::Write, AccessMode::Read) => {
AccessMode::ReadWrite
}
(mode, _) => mode,
};
};
let mut explicit_paths: HashMap<PathBuf, AccessMode> = HashMap::new();
let mut keychain_roots: HashMap<PathBuf, AccessMode> = HashMap::new();
for cap in caps.fs_capabilities().iter().filter(|cap| cap.is_file) {
if !is_keychain_db(&cap.resolved) {
continue;
}
explicit_paths
.entry(cap.resolved.clone())
.and_modify(|mode| merge_access(mode, cap.access))
.or_insert(cap.access);
if let Some(root) = cap.resolved.parent() {
keychain_roots
.entry(root.to_path_buf())
.and_modify(|mode| merge_access(mode, cap.access))
.or_insert(cap.access);
}
}
let mut allow_rules = Vec::new();
for (path, access) in explicit_paths {
let path_str = match path_to_utf8(&path) {
Ok(s) => s,
Err(e) => {
warn!(
"Skipping keychain DB exception for {}: {}",
path.display(),
e
);
continue;
}
};
let escaped = match escape_seatbelt_path(path_str) {
Ok(v) => v,
Err(e) => {
warn!(
"Skipping keychain DB exception for {}: {}",
path.display(),
e
);
continue;
}
};
let filter = format!("literal \"{}\"", escaped);
match access {
AccessMode::Read => {
allow_rules.push(format!("(allow file-read-data ({}))", filter));
allow_rules.push(format!("(allow file-read* ({}))", filter));
}
AccessMode::Write => {
allow_rules.push(format!("(allow file-write-data ({}))", filter));
allow_rules.push(format!("(allow file-write* ({}))", filter));
}
AccessMode::ReadWrite => {
allow_rules.push(format!("(allow file-read-data ({}))", filter));
allow_rules.push(format!("(allow file-read* ({}))", filter));
allow_rules.push(format!("(allow file-write-data ({}))", filter));
allow_rules.push(format!("(allow file-write* ({}))", filter));
}
}
}
for (root, access) in keychain_roots {
let root_str = match path_to_utf8(&root) {
Ok(s) => s,
Err(e) => {
warn!(
"Skipping keychain runtime exception for {}: {}",
root.display(),
e
);
continue;
}
};
let escaped_root = match escape_seatbelt_regex_path(root_str) {
Ok(v) => v,
Err(e) => {
warn!(
"Skipping keychain runtime exception for {}: {}",
root.display(),
e
);
continue;
}
};
let filters = [
format!(r#"regex #"^{}/\.fl[0-9A-Fa-f]+$""#, escaped_root),
format!(
r#"regex #"^{}/[^/]+/(?:[^/]+\.db(?:-(?:wal|shm))?|user\.kb)$""#,
escaped_root
),
];
for filter in filters {
match access {
AccessMode::Read => {
allow_rules.push(format!("(allow file-read-data ({}))", filter));
allow_rules.push(format!("(allow file-read* ({}))", filter));
}
AccessMode::Write => {
allow_rules.push(format!("(allow file-write-data ({}))", filter));
allow_rules.push(format!("(allow file-write* ({}))", filter));
}
AccessMode::ReadWrite => {
allow_rules.push(format!("(allow file-read-data ({}))", filter));
allow_rules.push(format!("(allow file-read* ({}))", filter));
allow_rules.push(format!("(allow file-write-data ({}))", filter));
allow_rules.push(format!("(allow file-write* ({}))", filter));
}
}
}
}
allow_rules.sort_unstable();
for rule in allow_rules {
if let Err(e) = caps.add_platform_rule(rule) {
warn!("Failed to add keychain DB exception rule: {}", e);
}
}
}
pub fn apply_deny_overrides(
overrides: &[std::path::PathBuf],
deny_paths: &mut Vec<PathBuf>,
caps: &mut CapabilitySet,
) -> Result<()> {
if overrides.is_empty() {
return Ok(());
}
for override_path in overrides {
let path_str = override_path.to_str().ok_or_else(|| {
NonoError::ConfigParse(format!(
"Override path contains non-UTF-8 bytes: {}",
override_path.display()
))
})?;
let expanded = expand_path(path_str)?;
let canonical = if expanded.exists() {
expanded.canonicalize().map_err(|e| {
NonoError::ConfigParse(format!(
"Failed to canonicalize override path {}: {}",
expanded.display(),
e
))
})?
} else {
expanded.clone()
};
let mut grant_has_read = false;
let mut grant_has_write = false;
for cap in caps.fs_capabilities() {
if !cap.source.is_user_intent() {
continue;
}
let covers = if cap.is_file {
cap.resolved == canonical
} else {
canonical.starts_with(&cap.resolved)
};
if covers {
match cap.access {
AccessMode::Read => grant_has_read = true,
AccessMode::Write => grant_has_write = true,
AccessMode::ReadWrite => {
grant_has_read = true;
grant_has_write = true;
}
}
}
}
if !grant_has_read && !grant_has_write {
return Err(NonoError::SandboxInit(format!(
"override_deny '{}' has no matching grant. \
Add a filesystem allow (--allow, --read, --write, or profile filesystem/policy) \
for this path.",
override_path.display(),
)));
}
info!(
"override_deny relaxing deny rule for '{}'",
canonical.display()
);
if cfg!(target_os = "macos") {
let mut override_paths = vec![canonical.clone()];
if expanded != canonical {
override_paths.push(expanded.clone());
}
for op in &override_paths {
let path_utf8 = path_to_utf8(op)?;
let escaped = escape_seatbelt_path(path_utf8)?;
let filter = if op.exists() && op.is_file() {
format!("literal \"{}\"", escaped)
} else {
format!("subpath \"{}\"", escaped)
};
if grant_has_read {
caps.add_platform_rule(format!("(allow file-read-data ({}))", filter))?;
}
if grant_has_write {
caps.add_platform_rule(format!("(allow file-write* ({}))", filter))?;
}
}
}
deny_paths.retain(|dp| !dp.starts_with(&canonical) && !dp.starts_with(&expanded));
}
Ok(())
}
pub fn apply_unlink_overrides(caps: &mut CapabilitySet) {
if cfg!(target_os = "linux") {
return; }
let mut unlink_rules = Vec::new();
for cap in caps
.fs_capabilities()
.iter()
.filter(|cap| matches!(cap.access, AccessMode::Write | AccessMode::ReadWrite))
{
for path in [&cap.original, &cap.resolved] {
let path_str = match path_to_utf8(path) {
Ok(s) => s,
Err(e) => {
tracing::warn!("Skipping unlink override for {}: {}", path.display(), e);
continue;
}
};
let escaped = match escape_seatbelt_path(path_str) {
Ok(e) => e,
Err(e) => {
tracing::warn!("Skipping unlink override for {}: {}", path.display(), e);
continue;
}
};
let filter = if cap.is_file {
format!("literal \"{}\"", escaped)
} else {
format!("subpath \"{}\"", escaped)
};
unlink_rules.push(format!("(allow file-write-unlink ({}))", filter));
}
}
unlink_rules.sort_unstable();
unlink_rules.dedup();
for rule in unlink_rules {
if let Err(e) = caps.add_platform_rule(rule) {
tracing::warn!("Skipping unlink override rule: {}", e);
}
}
}
pub fn resolve_deny_paths_for_groups(
policy: &Policy,
group_names: &[String],
) -> Result<Vec<PathBuf>> {
let mut tmp_caps = CapabilitySet::new();
let resolved = resolve_groups(policy, group_names, &mut tmp_caps)?;
Ok(resolved.deny_paths)
}
pub fn validate_deny_overlaps(deny_paths: &[PathBuf], caps: &CapabilitySet) -> Result<()> {
if cfg!(target_os = "macos") {
return Ok(());
}
let mut fatal_conflicts = Vec::new();
for deny_path in deny_paths {
for cap in caps.fs_capabilities() {
if cap.is_file {
continue; }
if deny_path.starts_with(&cap.resolved) && *deny_path != cap.resolved {
let conflict = format!(
"deny '{}' overlaps allowed parent '{}' (source: {})",
deny_path.display(),
cap.resolved.display(),
cap.source,
);
warn!(
"Landlock cannot enforce {}. This deny has no effect on Linux.",
conflict
);
fatal_conflicts.push(conflict);
}
}
}
if fatal_conflicts.is_empty() {
return Ok(());
}
fatal_conflicts.sort();
fatal_conflicts.dedup();
let preview = fatal_conflicts
.iter()
.take(5)
.map(|c| format!("- {}", c))
.collect::<Vec<_>>()
.join("\n");
let remainder = fatal_conflicts.len().saturating_sub(5);
let more = if remainder > 0 {
format!("\n- ... and {} more conflict(s)", remainder)
} else {
String::new()
};
Err(NonoError::SandboxInit(format!(
"Landlock deny-overlap is not enforceable on Linux. Refusing to start with conflicting policy.\n\
Remove the broad allow path, remove the deny path, or restructure permissions.\n\
Conflicts:\n{}{}",
preview, more
)))
}
#[cfg(test)]
pub fn list_groups(policy: &Policy) -> Vec<&str> {
let mut names: Vec<&str> = policy.groups.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
#[cfg(test)]
pub fn group_description<'a>(policy: &'a Policy, name: &str) -> Option<&'a str> {
policy.groups.get(name).map(|g| g.description.as_str())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SensitivePathRule {
pub expanded_path: String,
pub group_name: String,
pub description: String,
}
pub fn get_sensitive_paths(policy: &Policy) -> Result<Vec<SensitivePathRule>> {
let mut result = Vec::new();
for (group_name, group) in &policy.groups {
if !group_matches_platform(group) {
continue;
}
if let Some(deny) = &group.deny {
for path_str in &deny.access {
let expanded = expand_path(path_str)?;
result.push(SensitivePathRule {
expanded_path: expanded.to_string_lossy().into_owned(),
group_name: group_name.clone(),
description: group.description.clone(),
});
if expanded.is_symlink() {
if let Ok(resolved) = expanded.canonicalize() {
if resolved != expanded && !should_skip_resolved_deny_target(&resolved) {
result.push(SensitivePathRule {
expanded_path: resolved.to_string_lossy().into_owned(),
group_name: group_name.clone(),
description: group.description.clone(),
});
}
}
}
}
}
}
Ok(result)
}
pub fn get_dangerous_commands(policy: &Policy) -> HashSet<String> {
let mut result = HashSet::new();
for group in policy.groups.values() {
if !group_matches_platform(group) {
continue;
}
if let Some(deny) = &group.deny {
for cmd in &deny.commands {
result.insert(cmd.clone());
}
}
}
result
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn get_system_read_paths(policy: &Policy) -> Vec<String> {
let mut result = Vec::new();
for group in policy.groups.values() {
if !group_matches_platform(group) {
continue;
}
if let Some(allow) = &group.allow {
result.extend(allow.read.iter().cloned());
}
}
result
}
pub fn validate_group_exclusions(policy: &Policy, excluded_groups: &[String]) -> Result<()> {
let violations: Vec<&String> = excluded_groups
.iter()
.filter(|name| policy.groups.get(name.as_str()).is_some_and(|g| g.required))
.collect();
if violations.is_empty() {
return Ok(());
}
let names = violations
.iter()
.map(|n| format!("'{}'", n))
.collect::<Vec<_>>()
.join(", ");
Err(NonoError::ConfigParse(format!(
"Cannot exclude required groups: {}",
names
)))
}
pub fn get_policy_profile(name: &str) -> Result<Option<profile::Profile>> {
let policy = load_embedded_policy()?;
match policy.profiles.get(name) {
Some(def) => Ok(Some(crate::profile::resolve_and_finalize_profile(
def.to_raw_profile(),
)?)),
None => Ok(None),
}
}
pub fn list_policy_profiles() -> Result<Vec<String>> {
let policy = load_embedded_policy()?;
let mut names: Vec<String> = policy.profiles.keys().cloned().collect();
names.sort();
Ok(names)
}
pub fn load_embedded_policy() -> Result<Policy> {
static CACHED: std::sync::OnceLock<Policy> = std::sync::OnceLock::new();
if let Some(policy) = CACHED.get() {
return Ok(policy.clone());
}
let json = crate::config::embedded::embedded_policy_json();
let mut policy = load_policy(json)?;
load_package_groups(&mut policy)?;
let _ = CACHED.set(policy.clone());
Ok(policy)
}
pub fn load_package_groups(policy: &mut Policy) -> Result<()> {
let lockfile = match package::read_lockfile() {
Ok(lf) => lf,
Err(_) => return Ok(()),
};
for package_key in lockfile.packages.keys() {
let (namespace, name) = package_key.split_once('/').ok_or_else(|| {
NonoError::PackageInstall(format!("invalid lockfile package key '{package_key}'"))
})?;
let groups_path = package::package_groups_path(namespace, name)?;
if !groups_path.exists() {
continue;
}
let content = std::fs::read_to_string(&groups_path).map_err(|e| NonoError::ConfigRead {
path: groups_path.clone(),
source: e,
})?;
let groups: HashMap<String, Group> = serde_json::from_str(&content).map_err(|e| {
NonoError::ConfigParse(format!("failed to parse {}: {e}", groups_path.display()))
})?;
for (group_name, group) in groups {
if policy.groups.contains_key(&group_name) {
return Err(NonoError::PackageInstall(format!(
"package group '{}' collides with an existing policy group",
group_name
)));
}
policy.groups.insert(group_name, group);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_policy_json() -> &'static str {
r#"{
"meta": { "version": 2, "schema_version": "2.0" },
"groups": {
"test_read": {
"description": "Test read group",
"allow": { "read": ["/tmp"] }
},
"test_deny": {
"description": "Test deny group",
"deny": { "access": ["/nonexistent/test/path"] }
},
"test_commands": {
"description": "Test command blocking",
"deny": { "commands": ["rm", "dd"] }
},
"test_macos_only": {
"description": "macOS-only group",
"platform": "macos",
"allow": { "read": ["/tmp"] }
},
"test_linux_only": {
"description": "Linux-only group",
"platform": "linux",
"allow": { "read": ["/tmp"] }
},
"test_unlink": {
"description": "Unlink protection",
"deny": { "unlink": true }
},
"test_symlinks": {
"description": "Symlink test",
"symlink_pairs": { "/etc": "/private/etc" }
},
"test_required": {
"description": "Required deny group",
"required": true,
"deny": { "access": ["/nonexistent/required/path"] }
}
}
}"#
}
#[test]
fn test_load_policy() {
let policy = load_policy(sample_policy_json());
assert!(policy.is_ok());
let policy = policy.expect("parse failed");
assert_eq!(policy.meta.version, 2);
assert_eq!(policy.groups.len(), 8);
}
#[test]
fn test_load_embedded_policy() {
let json = crate::config::embedded::embedded_policy_json();
let policy = load_policy(json);
assert!(policy.is_ok(), "Failed to parse embedded policy.json");
let policy = policy.expect("parse failed");
assert!(policy.meta.version >= 2);
assert!(!policy.groups.is_empty());
}
#[test]
fn test_embedded_claude_code_profile_was_removed() {
let policy = load_embedded_policy().expect("embedded policy");
assert!(
!policy.profiles.contains_key("claude-code"),
"claude-code profile must not be in the embedded policy.json"
);
assert!(policy.groups.contains_key("claude_code_macos"));
assert!(policy.groups.contains_key("claude_code_linux"));
}
#[test]
fn test_embedded_claude_code_platform_groups_have_expected_paths() {
let policy = load_embedded_policy().expect("embedded policy");
let claude_code_macos = policy
.groups
.get("claude_code_macos")
.expect("claude_code_macos group missing");
assert_eq!(claude_code_macos.platform.as_deref(), Some("macos"));
let claude_code_macos_paths = &claude_code_macos
.allow
.as_ref()
.expect("claude_code_macos allow missing")
.readwrite;
assert!(claude_code_macos
.allow
.as_ref()
.expect("claude_code_macos allow missing")
.read
.contains(&"$HOME/.local/share/claude".to_string()));
assert!(claude_code_macos
.allow
.as_ref()
.expect("claude_code_macos allow missing")
.read
.contains(&"$HOME/Applications/Claude Code URL Handler.app".to_string()));
assert!(claude_code_macos_paths.contains(&"$HOME/Library/Keychains".to_string()));
assert!(claude_code_macos_paths
.contains(&"$HOME/Library/Keychains/login.keychain-db".to_string()));
assert!(claude_code_macos_paths
.contains(&"$HOME/Library/Keychains/metadata.keychain-db".to_string()));
let claude_code_linux = policy
.groups
.get("claude_code_linux")
.expect("claude_code_linux group missing");
assert_eq!(claude_code_linux.platform.as_deref(), Some("linux"));
assert!(claude_code_linux
.allow
.as_ref()
.expect("claude_code_linux allow missing")
.read
.contains(&"$HOME/.local/share/claude".to_string()));
let vscode_macos = policy
.groups
.get("vscode_macos")
.expect("vscode_macos group missing");
assert_eq!(vscode_macos.platform.as_deref(), Some("macos"));
let vscode_macos_paths = &vscode_macos
.allow
.as_ref()
.expect("vscode_macos allow missing")
.readwrite;
assert!(vscode_macos_paths.contains(&"$HOME/.vscode".to_string()));
assert!(vscode_macos_paths.contains(&"$HOME/Library/Application Support/Code".to_string()));
let vscode_linux = policy
.groups
.get("vscode_linux")
.expect("vscode_linux group missing");
assert_eq!(vscode_linux.platform.as_deref(), Some("linux"));
let vscode_linux_paths = &vscode_linux
.allow
.as_ref()
.expect("vscode_linux allow missing")
.readwrite;
assert!(vscode_linux_paths.contains(&"$HOME/.vscode".to_string()));
assert!(vscode_linux_paths.contains(&"$HOME/.config/Code".to_string()));
}
#[test]
fn test_embedded_claude_code_platform_groups_filter_by_os() {
let policy = load_embedded_policy().expect("embedded policy");
let mut caps = CapabilitySet::new();
let resolved = resolve_groups(
&policy,
&[
"claude_code_macos".to_string(),
"claude_code_linux".to_string(),
"vscode_macos".to_string(),
"vscode_linux".to_string(),
],
&mut caps,
)
.expect("resolve failed");
assert_eq!(resolved.names.len(), 2);
if cfg!(target_os = "macos") {
assert!(resolved.names.contains(&"claude_code_macos".to_string()));
assert!(resolved.names.contains(&"vscode_macos".to_string()));
} else {
assert!(resolved.names.contains(&"claude_code_linux".to_string()));
assert!(resolved.names.contains(&"vscode_linux".to_string()));
}
}
#[test]
fn test_resolve_read_group() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved = resolve_groups(&policy, &["test_read".to_string()], &mut caps);
assert!(resolved.is_ok());
assert!(caps.has_fs());
}
#[test]
fn test_resolve_deny_group() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved =
resolve_groups(&policy, &["test_deny".to_string()], &mut caps).expect("resolve failed");
assert!(!resolved.deny_paths.is_empty());
if cfg!(target_os = "macos") {
assert!(!caps.platform_rules().is_empty());
let rules = caps.platform_rules().join("\n");
assert!(rules.contains("deny file-read-data"));
assert!(rules.contains("deny file-write*"));
assert!(rules.contains("allow file-read-metadata"));
} else {
assert!(caps.platform_rules().is_empty());
}
}
#[test]
fn test_resolve_command_group() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved = resolve_groups(&policy, &["test_commands".to_string()], &mut caps);
assert!(resolved.is_ok());
assert!(caps.blocked_commands().contains(&"rm".to_string()));
assert!(caps.blocked_commands().contains(&"dd".to_string()));
}
#[test]
fn test_platform_filtering() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved = resolve_groups(
&policy,
&["test_macos_only".to_string(), "test_linux_only".to_string()],
&mut caps,
)
.expect("resolve failed");
assert_eq!(resolved.names.len(), 1);
if cfg!(target_os = "macos") {
assert_eq!(resolved.names[0], "test_macos_only");
} else {
assert_eq!(resolved.names[0], "test_linux_only");
}
}
#[test]
fn test_unknown_group_error() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let result = resolve_groups(&policy, &["nonexistent_group".to_string()], &mut caps);
assert!(result.is_err());
}
#[test]
fn test_unlink_protection() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved = resolve_groups(&policy, &["test_unlink".to_string()], &mut caps);
assert!(resolved.is_ok());
if cfg!(target_os = "macos") {
assert!(caps
.platform_rules()
.iter()
.any(|r| r.contains("deny file-write-unlink")));
} else {
assert!(!caps
.platform_rules()
.iter()
.any(|r| r.contains("deny file-write-unlink")));
}
}
#[test]
fn test_symlink_pairs() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved = resolve_groups(&policy, &["test_symlinks".to_string()], &mut caps);
assert!(resolved.is_ok());
if cfg!(target_os = "macos") {
assert!(caps.platform_rules().iter().any(|r| r.contains("/etc")));
} else {
assert!(caps.platform_rules().is_empty());
}
}
#[test]
fn test_expand_path_tilde() {
let path = expand_path("~/.ssh").expect("HOME must be valid");
assert!(path.to_string_lossy().contains(".ssh"));
assert!(!path.to_string_lossy().starts_with("~"));
}
#[test]
fn test_expand_path_tmpdir() {
let path = expand_path("$TMPDIR").expect("TMPDIR must be valid");
assert!(!path.to_string_lossy().starts_with("$"));
}
#[test]
fn test_expand_path_absolute() {
let path = expand_path("/usr/bin").expect("absolute path needs no env");
assert_eq!(path, PathBuf::from("/usr/bin"));
}
#[test]
fn test_list_groups() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let names = list_groups(&policy);
assert!(names.contains(&"test_read"));
assert!(names.contains(&"test_deny"));
}
#[test]
fn test_group_description() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
assert_eq!(
group_description(&policy, "test_read"),
Some("Test read group")
);
assert_eq!(group_description(&policy, "nonexistent"), None);
}
#[test]
fn test_deny_access_collects_path_and_generates_rules() {
let mut caps = CapabilitySet::new();
let mut deny_paths = Vec::new();
add_deny_access_rules("/nonexistent/test/deny", &mut caps, &mut deny_paths)
.expect("expand_path should succeed for absolute paths");
assert_eq!(deny_paths.len(), 1);
assert_eq!(deny_paths[0], PathBuf::from("/nonexistent/test/deny"));
if cfg!(target_os = "macos") {
let rules = caps.platform_rules();
assert_eq!(rules.len(), 4);
assert!(rules[0].contains("allow file-read-metadata"));
assert!(rules[1].contains("deny file-read-data"));
assert!(rules[2].contains("deny file-write*"));
assert!(rules[3].contains("deny network-outbound"));
} else {
assert!(caps.platform_rules().is_empty());
}
}
#[test]
fn test_deny_access_includes_symlink_target() {
let dir = tempfile::tempdir().expect("create tempdir");
let target = dir.path().join("real_file");
std::fs::write(&target, "secret").expect("write target");
let link = dir.path().join("link_file");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let mut caps = CapabilitySet::new();
let mut deny_paths = Vec::new();
let link_str = link.to_str().expect("valid utf8");
add_deny_access_rules(link_str, &mut caps, &mut deny_paths)
.expect("add deny rules for symlink");
let link_canonical = link.canonicalize().expect("canonicalize link");
assert!(
deny_paths.contains(&link),
"deny_paths must contain the symlink path"
);
assert!(
deny_paths.contains(&link_canonical),
"deny_paths must contain the resolved target path"
);
if cfg!(target_os = "macos") {
let rules = caps.platform_rules();
assert_eq!(rules.len(), 8, "expected 8 Seatbelt rules for symlink deny");
}
}
#[test]
fn test_deny_access_non_symlink_no_duplicate() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("regular_file");
std::fs::write(&file, "content").expect("write file");
let canonical = file.canonicalize().expect("canonicalize");
let parent_is_symlinked = canonical != file;
let mut caps = CapabilitySet::new();
let mut deny_paths = Vec::new();
let file_str = file.to_str().expect("valid utf8");
add_deny_access_rules(file_str, &mut caps, &mut deny_paths)
.expect("add deny rules for regular file");
let expected = if parent_is_symlinked { 2 } else { 1 };
assert_eq!(
deny_paths.len(),
expected,
"deny_paths entries: expected {} (parent symlinked: {}), got {:?}",
expected,
parent_is_symlinked,
deny_paths
);
}
#[test]
fn test_resolve_parent_symlinks_nonexistent_leaf() {
let dir = tempfile::tempdir().expect("create tempdir");
let real_dir = dir.path().join("real_dir");
std::fs::create_dir(&real_dir).expect("create real_dir");
let link_dir = dir.path().join("link_dir");
std::os::unix::fs::symlink(&real_dir, &link_dir).expect("create symlink");
let future = link_dir.join("future.sock");
assert!(!future.exists(), "test precondition: leaf must not exist");
let result = resolve_parent_symlinks(&future).expect("resolve_parent_symlinks");
let real_dir_canonical = real_dir.canonicalize().expect("canonicalize real_dir");
let expected = real_dir_canonical.join("future.sock");
if link_dir.canonicalize().ok().as_deref() != Some(&*real_dir_canonical)
|| link_dir != real_dir
{
assert_eq!(result, Some(expected));
}
}
#[test]
fn test_resolve_parent_symlinks_existing_path() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("existing.txt");
std::fs::write(&file, "content").expect("write file");
let result = resolve_parent_symlinks(&file).expect("resolve_parent_symlinks");
let canonical = file.canonicalize().expect("canonicalize");
if canonical == file {
assert_eq!(result, None, "no parent symlinks means None");
} else {
assert_eq!(
result,
Some(canonical),
"parent symlinks means Some(canonical)"
);
}
}
#[test]
fn test_deny_access_nonexistent_under_symlinked_parent() {
let dir = tempfile::tempdir().expect("create tempdir");
let real_dir = dir.path().join("real_run");
std::fs::create_dir(&real_dir).expect("create real_run");
let link_dir = dir.path().join("run");
std::os::unix::fs::symlink(&real_dir, &link_dir).expect("create symlink");
let socket_path = link_dir.join("daemon.sock");
assert!(
!socket_path.exists(),
"test precondition: socket must not exist"
);
let mut caps = CapabilitySet::new();
let mut deny_paths = Vec::new();
let path_str = socket_path.to_str().expect("valid utf8");
add_deny_access_rules(path_str, &mut caps, &mut deny_paths)
.expect("add deny rules for non-existent socket under symlinked parent");
let real_dir_canonical = real_dir.canonicalize().expect("canonicalize real_dir");
let resolved_socket = real_dir_canonical.join("daemon.sock");
let parent_is_symlinked = link_dir.canonicalize().ok().as_deref()
!= Some(&*real_dir_canonical)
|| link_dir != real_dir;
if parent_is_symlinked && resolved_socket != socket_path {
assert!(
deny_paths.contains(&socket_path.to_path_buf()),
"deny_paths must contain the original path"
);
assert!(
deny_paths.contains(&resolved_socket),
"deny_paths must contain the parent-resolved path; got {:?}",
deny_paths
);
if cfg!(target_os = "macos") {
let rules = caps.platform_rules();
assert_eq!(
rules.len(),
8,
"expected 8 Seatbelt rules (4 original + 4 resolved); got: {:?}",
rules
);
}
} else {
assert!(deny_paths.contains(&socket_path.to_path_buf()));
}
}
#[test]
fn test_sensitive_paths_includes_symlink_targets() {
let dir = tempfile::tempdir().expect("create tempdir");
let target = dir.path().join("real_config");
std::fs::write(&target, "secret").expect("write target");
let link = dir.path().join("link_config");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let link_str = link.to_str().expect("valid utf8");
let json = format!(
r#"{{
"meta": {{ "version": 1, "schema_version": "1.0" }},
"groups": {{
"test_deny_symlink": {{
"description": "Test deny with symlink",
"deny": {{ "access": ["{}"] }}
}}
}}
}}"#,
link_str
);
let policy = load_policy(&json).expect("parse test policy");
let sensitive = get_sensitive_paths(&policy).expect("get sensitive paths");
let link_canonical = link.canonicalize().expect("canonicalize");
let paths: Vec<&str> = sensitive
.iter()
.map(|rule| rule.expanded_path.as_str())
.collect();
assert!(
paths.contains(&link_str),
"sensitive paths must contain symlink path"
);
assert!(
paths.contains(&link_canonical.to_str().expect("utf8")),
"sensitive paths must contain resolved target"
);
}
#[test]
fn test_should_skip_resolved_deny_target() {
let nix_paths = [
Path::new("/nix/store/abc123-home-manager-files/.zshrc"),
Path::new("/nix/store/xyz789-zsh-5.9/share/zsh"),
Path::new("/nix/store"),
];
let non_nix_paths = [
Path::new("/home/user/.zshrc"),
Path::new("/nix/var/nix/profiles/default"),
Path::new("/nix"),
Path::new("/nix/stored-elsewhere"),
Path::new("/tmp/nix/store/fake"),
];
for p in &nix_paths {
if cfg!(target_os = "linux") {
assert!(
should_skip_resolved_deny_target(p),
"Linux must skip Nix store target: {}",
p.display()
);
} else {
assert!(
!should_skip_resolved_deny_target(p),
"macOS must NOT skip Nix store target (Seatbelt needs it): {}",
p.display()
);
}
}
for p in &non_nix_paths {
assert!(
!should_skip_resolved_deny_target(p),
"must never skip non-Nix-store path: {}",
p.display()
);
}
}
#[test]
fn test_sensitive_paths_nix_store_symlink_end_to_end() {
let dir = tempfile::tempdir().expect("create tempdir");
let target = dir.path().join("real_zshrc");
std::fs::write(&target, "config").expect("write");
let link = dir.path().join("linked_zshrc");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let link_str = link.to_str().expect("utf8");
let json = format!(
r#"{{
"meta": {{ "version": 1, "schema_version": "1.0" }},
"groups": {{
"test_deny": {{
"description": "Test deny",
"deny": {{ "access": ["{}"] }}
}}
}}
}}"#,
link_str
);
let policy = load_policy(&json).expect("parse");
let sensitive = get_sensitive_paths(&policy).expect("sensitive paths");
let canonical = link.canonicalize().expect("canonicalize");
let paths: Vec<&str> = sensitive.iter().map(|r| r.expanded_path.as_str()).collect();
assert!(
!should_skip_resolved_deny_target(&canonical),
"precondition: tempdir canonical is not a nix store path"
);
assert!(
paths.contains(&link_str),
"sensitive paths must contain the symlink path"
);
assert!(
paths.contains(&canonical.to_str().expect("utf8")),
"non-nix canonical target must be in sensitive paths"
);
}
#[test]
fn test_escape_seatbelt_path() {
assert_eq!(
escape_seatbelt_path("/simple/path").expect("simple path"),
"/simple/path"
);
assert_eq!(
escape_seatbelt_path("/path with\\slash").expect("backslash"),
"/path with\\\\slash"
);
assert_eq!(
escape_seatbelt_path("/path\"quoted").expect("quote"),
"/path\\\"quoted"
);
}
#[test]
fn test_escape_seatbelt_path_rejects_control_chars() {
assert!(escape_seatbelt_path("/path\nwith\nnewlines").is_err());
assert!(escape_seatbelt_path("/path\rwith\rreturns").is_err());
assert!(escape_seatbelt_path("/path\0with\0nulls").is_err());
assert!(escape_seatbelt_path("/path\twith\ttabs").is_err());
assert!(escape_seatbelt_path("/path\x0bwith\x0cfeeds").is_err());
assert!(escape_seatbelt_path("/path\x1bwith\x1bescape").is_err());
assert!(escape_seatbelt_path("/path\x7fwith\x7fdel").is_err());
}
#[test]
fn test_escape_seatbelt_path_injection_via_newline() {
let malicious = "/tmp/evil\n(allow file-read* (subpath \"/\"))";
assert!(escape_seatbelt_path(malicious).is_err());
}
#[test]
fn test_escape_seatbelt_path_injection_via_quote() {
let malicious = "/tmp/evil\")(allow file-read* (subpath \"/\"))(\"";
let escaped = escape_seatbelt_path(malicious).expect("no control chars");
let chars: Vec<char> = escaped.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' {
assert!(
i > 0 && chars[i - 1] == '\\',
"unescaped quote at position {}",
i
);
}
}
}
#[test]
fn test_escape_seatbelt_regex_path() {
assert_eq!(
escape_seatbelt_regex_path("/simple/path").expect("simple path"),
"/simple/path"
);
assert_eq!(
escape_seatbelt_regex_path("/path.with+regex?(chars)").expect("regex chars"),
"/path\\.with\\+regex\\?\\(chars\\)"
);
assert_eq!(
escape_seatbelt_regex_path("/path\"quoted").expect("quote"),
"/path\\\"quoted"
);
}
#[test]
fn test_escape_seatbelt_regex_path_rejects_control_chars() {
assert!(escape_seatbelt_regex_path("/path\nwith\nnewlines").is_err());
assert!(escape_seatbelt_regex_path("/path\rwith\rreturns").is_err());
assert!(escape_seatbelt_regex_path("/path\0with\0nulls").is_err());
assert!(escape_seatbelt_regex_path("/path\twith\ttabs").is_err());
assert!(escape_seatbelt_regex_path("/path\x0bwith\x0cfeeds").is_err());
assert!(escape_seatbelt_regex_path("/path\x1bwith\x1bescape").is_err());
assert!(escape_seatbelt_regex_path("/path\x7fwith\x7fdel").is_err());
}
#[test]
fn test_validate_deny_overlaps_detects_conflict() {
use nono::FsCapability;
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(std::path::Path::new("/tmp"), AccessMode::Read)
.expect("/tmp must exist");
caps.add_fs(cap);
let deny_paths = vec![PathBuf::from("/tmp/secret")];
if cfg!(target_os = "linux") {
let has_overlap = deny_paths.iter().any(|deny| {
caps.fs_capabilities().iter().any(|cap| {
!cap.is_file && deny.starts_with(&cap.resolved) && *deny != cap.resolved
})
});
assert!(
has_overlap,
"Should detect /tmp/secret overlapping with /tmp"
);
}
if cfg!(target_os = "linux") {
let err = validate_deny_overlaps(&deny_paths, &caps)
.expect_err("Expected overlap to fail on Linux");
assert!(
err.to_string().contains("Landlock deny-overlap"),
"Expected deny-overlap error message, got: {err}"
);
} else {
validate_deny_overlaps(&deny_paths, &caps).expect("no-op on macOS");
}
}
#[test]
fn test_validate_deny_overlaps_no_false_positive() {
use nono::FsCapability;
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(std::path::Path::new("/tmp"), AccessMode::Read)
.expect("/tmp must exist");
caps.add_fs(cap);
let deny_paths = vec![PathBuf::from("/home/secret")];
let has_overlap = deny_paths.iter().any(|deny| {
caps.fs_capabilities()
.iter()
.any(|cap| !cap.is_file && deny.starts_with(&cap.resolved) && *deny != cap.resolved)
});
assert!(
!has_overlap,
"Should not detect overlap for unrelated paths"
);
validate_deny_overlaps(&deny_paths, &caps).expect("No overlap should succeed");
}
#[cfg(target_os = "linux")]
#[test]
fn test_should_skip_system_write_linux_tmp_grant_when_home_is_nested() {
let temp_root = tempfile::tempdir().expect("tmpdir").keep();
let home = temp_root.join("home");
std::fs::create_dir_all(&home).expect("create home");
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let original_tmpdir = std::env::var("TMPDIR").unwrap_or("/tmp".to_string());
let _env = crate::test_env::EnvVarGuard::set_all(&[
("HOME", home.to_str().expect("home path")),
("TMPDIR", temp_root.to_str().expect("tmpdir path")),
]);
let skip_tmp =
should_skip_group_allow_path("system_write_linux", Path::new(&original_tmpdir))
.expect("check original TMPDIR skip");
let skip_tmpdir = should_skip_group_allow_path("system_write_linux", &temp_root)
.expect("check new TMPDIR skip");
assert!(
skip_tmp,
"original TMPDIR should be skipped when HOME is nested under it"
);
assert!(
skip_tmpdir,
"$TMPDIR should be skipped when HOME is nested under it"
);
}
#[test]
#[cfg(target_os = "linux")]
fn test_validate_deny_overlaps_group_overlap_is_fatal() {
use nono::FsCapability;
let mut caps = CapabilitySet::new();
let mut cap = FsCapability::new_dir(std::path::Path::new("/tmp"), AccessMode::Read)
.expect("/tmp must exist");
cap.source = CapabilitySource::Group("user_tools".to_string());
caps.add_fs(cap);
let deny_paths = vec![PathBuf::from("/tmp/secret")];
let result = validate_deny_overlaps(&deny_paths, &caps);
assert!(
result.is_err(),
"group-sourced deny overlap must be a hard error on Linux"
);
}
#[test]
fn test_all_groups_no_deny_within_allow_overlap() {
let policy = load_embedded_policy().expect("embedded policy must load");
let is_linux_applicable =
|g: &Group| g.platform.is_none() || g.platform.as_deref() == Some("linux");
let mut deny_paths: Vec<(String, PathBuf)> = Vec::new();
let mut allow_paths: Vec<(String, PathBuf)> = Vec::new();
for (name, group) in &policy.groups {
if !is_linux_applicable(group) {
continue;
}
if let Some(deny) = &group.deny {
for p in &deny.access {
let expanded = expand_path(p).unwrap_or_else(|e| {
panic!("expand_path({p}) failed in group '{name}': {e}")
});
deny_paths.push((name.clone(), expanded));
}
}
if let Some(allow) = &group.allow {
for p in allow
.read
.iter()
.chain(&allow.write)
.chain(&allow.readwrite)
{
let expanded = expand_path(p).unwrap_or_else(|e| {
panic!("expand_path({p}) failed in group '{name}': {e}")
});
if should_skip_group_allow_path(name, &expanded).unwrap_or_else(|e| {
panic!(
"should_skip_group_allow_path({}, {}) failed: {e}",
name,
expanded.display()
)
}) {
continue;
}
allow_paths.push((name.clone(), expanded));
}
}
}
for (deny_group, deny_path) in &deny_paths {
for (allow_group, allow_path) in &allow_paths {
assert!(
!deny_path.starts_with(allow_path),
"Deny-within-allow overlap on Linux: deny '{}' (group: {}) \
is under or equal to allowed '{}' (group: {}). Landlock \
cannot enforce this. Narrow the allow path or move the \
deny into a separate explicit grant path.",
deny_path.display(),
deny_group,
allow_path.display(),
allow_group,
);
}
}
}
#[test]
fn test_resolve_deny_group_collects_deny_paths() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let mut caps = CapabilitySet::new();
let resolved =
resolve_groups(&policy, &["test_deny".to_string()], &mut caps).expect("resolve failed");
assert_eq!(resolved.deny_paths.len(), 1);
assert!(
resolved.deny_paths[0]
.to_string_lossy()
.contains("nonexistent/test/path"),
"Expected deny path to contain 'nonexistent/test/path', got: {}",
resolved.deny_paths[0].display()
);
}
#[test]
fn test_validate_group_exclusions_allows_non_required() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let result = validate_group_exclusions(&policy, &["test_read".to_string()]);
assert!(result.is_ok(), "Non-required group should be removable");
}
#[test]
fn test_validate_group_exclusions_rejects_required() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let result = validate_group_exclusions(&policy, &["test_required".to_string()]);
assert!(result.is_err(), "Required group must not be removable");
let err = result.expect_err("expected error");
assert!(
err.to_string().contains("test_required"),
"Error should name the group: {}",
err
);
}
#[test]
fn test_validate_group_exclusions_ignores_unknown() {
let policy = load_policy(sample_policy_json()).expect("parse failed");
let result = validate_group_exclusions(&policy, &["nonexistent_group".to_string()]);
assert!(
result.is_ok(),
"Unknown groups should not trigger required check"
);
}
#[test]
fn test_profile_def_to_raw_profile_combines_exclude_groups() {
let def = ProfileDef {
exclude_groups: vec!["preferred".to_string()],
policy: profile::PolicyPatchConfig {
exclude_groups: vec!["policy".to_string()],
..Default::default()
},
..Default::default()
};
let raw = def.to_raw_profile();
assert_eq!(
raw.policy.exclude_groups,
vec!["preferred".to_string(), "policy".to_string()]
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_resolve_character_device_files() {
let policy_json = r#"{
"meta": { "version": 2, "schema_version": "2.0" },
"groups": {
"test_devices": {
"description": "Device files",
"platform": "linux",
"allow": { "read": ["/dev/urandom", "/dev/null", "/dev/zero"] }
}
}
}"#;
let policy = load_policy(policy_json).expect("parse failed");
let mut caps = CapabilitySet::new();
resolve_groups(&policy, &["test_devices".to_string()], &mut caps).expect("resolve failed");
let resolved_paths: Vec<PathBuf> = caps
.fs_capabilities()
.iter()
.map(|c| c.resolved.clone())
.collect();
for device in &["/dev/urandom", "/dev/null", "/dev/zero"] {
assert!(
resolved_paths.iter().any(|p| p == Path::new(device)),
"{} must be included, got: {:?}",
device,
resolved_paths
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn test_embedded_policy_includes_device_files() {
let policy = load_embedded_policy().expect("embedded policy");
let mut caps = CapabilitySet::new();
resolve_groups(&policy, &["system_read_linux_core".to_string()], &mut caps)
.expect("resolve failed");
let resolved_paths: Vec<PathBuf> = caps
.fs_capabilities()
.iter()
.map(|c| c.resolved.clone())
.collect();
for device in &["/dev/urandom", "/dev/null", "/dev/zero", "/dev/random"] {
assert!(
resolved_paths.iter().any(|p| p == Path::new(device)),
"{} must be included in system_read_linux_core capabilities, got: {:?}",
device,
resolved_paths
);
}
}
#[test]
fn test_embedded_policy_required_groups() {
let policy = load_embedded_policy().expect("embedded policy");
let required: Vec<&str> = policy
.groups
.iter()
.filter(|(_, g)| g.required)
.map(|(name, _)| name.as_str())
.collect();
assert!(
required.contains(&"deny_credentials"),
"deny_credentials must be required"
);
assert!(
required.contains(&"deny_shell_configs"),
"deny_shell_configs must be required"
);
}
#[test]
fn test_system_read_linux_core_does_not_grant_bare_etc_or_proc() {
let policy = load_embedded_policy().expect("embedded policy must parse");
let group = policy
.groups
.get("system_read_linux_core")
.expect("system_read_linux_core group must exist");
let read_paths = group
.allow
.as_ref()
.map(|a| a.read.as_slice())
.unwrap_or(&[]);
assert!(
!read_paths.iter().any(|p| p == "/etc"),
"system_read_linux_core must not grant bare '/etc'; use specific paths instead. Found: {:?}",
read_paths
);
assert!(
!read_paths.iter().any(|p| p == "/proc"),
"system_read_linux_core must not grant bare '/proc'; use specific paths instead. Found: {:?}",
read_paths
);
}
#[test]
fn test_linux_core_excludes_runtime_state_sysfs_temp_and_nix() {
let policy = load_embedded_policy().expect("embedded policy must parse");
let group = policy
.groups
.get("system_read_linux_core")
.expect("system_read_linux_core group must exist");
let read_paths = group
.allow
.as_ref()
.map(|a| a.read.as_slice())
.unwrap_or(&[]);
for disallowed in ["/run", "/var/run", "/sys", "/tmp", "/nix"] {
assert!(
!read_paths.iter().any(|p| p == disallowed),
"system_read_linux_core must not include '{}'. Found: {:?}",
disallowed,
read_paths
);
}
}
#[test]
fn test_linux_compat_groups_expose_expected_paths() {
let policy = load_embedded_policy().expect("embedded policy must parse");
let runtime = policy
.groups
.get("linux_runtime_state")
.expect("linux_runtime_state group must exist");
let runtime_paths = runtime
.allow
.as_ref()
.map(|a| a.read.as_slice())
.unwrap_or(&[]);
assert!(runtime_paths.iter().any(|p| p == "/run"));
assert!(runtime_paths.iter().any(|p| p == "/var/run"));
let sysfs = policy
.groups
.get("linux_sysfs_read")
.expect("linux_sysfs_read group must exist");
let sysfs_paths = sysfs
.allow
.as_ref()
.map(|a| a.read.as_slice())
.unwrap_or(&[]);
assert_eq!(sysfs_paths, ["/sys"]);
let temp = policy
.groups
.get("linux_temp_read")
.expect("linux_temp_read group must exist");
let temp_paths = temp
.allow
.as_ref()
.map(|a| a.read.as_slice())
.unwrap_or(&[]);
assert_eq!(temp_paths, ["/tmp"]);
}
#[test]
fn test_default_user_groups_do_not_grant_local_state() {
let policy = load_embedded_policy().expect("embedded policy must parse");
let user_tools = policy
.groups
.get("user_tools")
.expect("user_tools group must exist");
let user_tools_allow = user_tools.allow.as_ref().expect("user_tools allow rules");
assert!(
!user_tools_allow.read.iter().any(|p| p == "~/.local/state"),
"user_tools must not grant ~/.local/state"
);
assert!(
!user_tools_allow
.readwrite
.iter()
.any(|p| p == "~/.local/state"),
"user_tools must not grant ~/.local/state"
);
let user_caches_linux = policy
.groups
.get("user_caches_linux")
.expect("user_caches_linux group must exist");
let user_caches_allow = user_caches_linux
.allow
.as_ref()
.expect("user_caches_linux allow rules");
assert!(
!user_caches_allow.read.iter().any(|p| p == "~/.local/state"),
"user_caches_linux must not grant ~/.local/state"
);
assert!(
!user_caches_allow
.readwrite
.iter()
.any(|p| p == "~/.local/state"),
"user_caches_linux must not grant ~/.local/state"
);
}
#[test]
fn test_apply_deny_overrides_removes_from_deny_paths() {
let dir = tempfile::tempdir().expect("tmpdir");
let denied = dir.path().join("denied");
std::fs::create_dir_all(&denied).expect("mkdir denied");
let other = dir.path().join("other");
std::fs::create_dir_all(&other).expect("mkdir other");
let denied_canonical = denied.canonicalize().expect("canonicalize denied");
let other_canonical = other.canonicalize().expect("canonicalize other");
let mut deny_paths = vec![denied_canonical.clone(), other_canonical.clone()];
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(dir.path(), AccessMode::ReadWrite).expect("grant dir"));
let overrides = vec![denied.clone()];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
assert_eq!(deny_paths.len(), 1);
assert_eq!(deny_paths[0], other_canonical);
}
#[test]
fn test_apply_deny_overrides_empty_is_noop() {
let mut deny_paths = vec![PathBuf::from("/tmp/denied")];
let mut caps = CapabilitySet::new();
apply_deny_overrides(&[], &mut deny_paths, &mut caps)
.expect("empty overrides should succeed");
assert_eq!(deny_paths.len(), 1, "deny_paths should be unchanged");
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_deny_overrides_emits_seatbelt_rules() {
let mut deny_paths = vec![PathBuf::from("/tmp")];
let mut caps = CapabilitySet::new();
caps.add_fs(
FsCapability::new_dir(Path::new("/tmp"), AccessMode::ReadWrite).expect("grant /tmp"),
);
let overrides = vec![PathBuf::from("/tmp")];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains("allow file-read-data"),
"should emit read allow rule, got: {}",
rules
);
assert!(
rules.contains("allow file-write*"),
"should emit write allow rule, got: {}",
rules
);
assert!(
rules.contains("subpath"),
"should use subpath for directory, got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_unlink_overrides_emits_literal_rule_for_writable_file_caps() {
let mut caps = CapabilitySet::new();
let file_path = PathBuf::from("/tmp/.claude.lock");
caps.add_fs(FsCapability {
original: file_path.clone(),
resolved: file_path.clone(),
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Profile,
});
apply_unlink_overrides(&mut caps);
let escaped =
escape_seatbelt_path(file_path.to_str().expect("utf8 path")).expect("escaped path");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains(&format!(
"(allow file-write-unlink (literal \"{}\"))",
escaped
)),
"expected literal unlink override for writable file cap, got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_macos_keychain_db_exception_adds_login_db_allow_rule() {
let mut caps = CapabilitySet::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".to_string());
let login_db = PathBuf::from(home).join("Library/Keychains/login.keychain-db");
caps.add_fs(FsCapability {
original: login_db.clone(),
resolved: login_db.clone(),
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Profile,
});
apply_macos_keychain_db_exception(&mut caps);
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains(&format!(
"(allow file-read* (literal \"{}\"))",
escape_seatbelt_path(login_db.to_str().expect("utf8 path")).expect("escaped path")
)),
"expected login keychain DB exception rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-write* (literal \"{}\"))",
escape_seatbelt_path(login_db.to_str().expect("utf8 path")).expect("escaped path")
)),
"expected login keychain DB write rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-read-data (literal \"{}\"))",
escape_seatbelt_path(login_db.to_str().expect("utf8 path")).expect("escaped path")
)),
"expected login keychain DB file-read-data rule (to override specific deny), got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-write-data (literal \"{}\"))",
escape_seatbelt_path(login_db.to_str().expect("utf8 path")).expect("escaped path")
)),
"expected login keychain DB file-write-data rule (to override specific deny), got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_macos_keychain_db_exception_adds_metadata_db_allow_rule() {
let mut caps = CapabilitySet::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".to_string());
let metadata_db = PathBuf::from(home).join("Library/Keychains/metadata.keychain-db");
caps.add_fs(FsCapability {
original: metadata_db.clone(),
resolved: metadata_db.clone(),
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Profile,
});
apply_macos_keychain_db_exception(&mut caps);
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains(&format!(
"(allow file-read* (literal \"{}\"))",
escape_seatbelt_path(metadata_db.to_str().expect("utf8 path"))
.expect("escaped path")
)),
"expected metadata keychain DB exception rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-write* (literal \"{}\"))",
escape_seatbelt_path(metadata_db.to_str().expect("utf8 path"))
.expect("escaped path")
)),
"expected metadata keychain DB write rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-read-data (literal \"{}\"))",
escape_seatbelt_path(metadata_db.to_str().expect("utf8 path"))
.expect("escaped path")
)),
"expected metadata keychain DB file-read-data rule (to override specific deny), got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-write-data (literal \"{}\"))",
escape_seatbelt_path(metadata_db.to_str().expect("utf8 path"))
.expect("escaped path")
)),
"expected metadata keychain DB file-write-data rule (to override specific deny), got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_macos_keychain_db_exception_adds_runtime_keychain_rules() {
let dir = tempfile::tempdir().expect("tmpdir");
let home = dir.path().join("home");
let keychains = home.join("Library/Keychains");
std::fs::create_dir_all(&keychains).expect("mkdir keychains");
std::fs::write(keychains.join("login.keychain-db"), "").expect("write login db");
std::fs::write(keychains.join("metadata.keychain-db"), "").expect("write metadata db");
let _env = crate::test_env::EnvVarGuard::set_all(&[(
"HOME",
home.to_str().expect("home path utf8"),
)]);
let login_db = keychains.join("login.keychain-db");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: login_db.clone(),
resolved: login_db,
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Profile,
});
apply_macos_keychain_db_exception(&mut caps);
let escaped_root =
escape_seatbelt_regex_path(keychains.to_str().expect("keychains path utf8"))
.expect("escaped regex path");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains(&format!(
"(allow file-read* (regex #\"^{}/\\.fl[0-9A-Fa-f]+$\"))",
escaped_root
)),
"expected root .fl keychain rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-write* (regex #\"^{}/\\.fl[0-9A-Fa-f]+$\"))",
escaped_root
)),
"expected writable root .fl keychain rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-read* (regex #\"^{}/[^/]+/(?:[^/]+\\.db(?:-(?:wal|shm))?|user\\.kb)$\"))",
escaped_root
)),
"expected runtime DB read rule, got: {}",
rules
);
assert!(
rules.contains(&format!(
"(allow file-write* (regex #\"^{}/[^/]+/(?:[^/]+\\.db(?:-(?:wal|shm))?|user\\.kb)$\"))",
escaped_root
)),
"expected runtime DB write rule, got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_deny_overrides_respects_read_only_grant() {
let dir = tempfile::tempdir().expect("tmpdir");
let denied = dir.path().join("readonly");
std::fs::create_dir_all(&denied).expect("mkdir");
let denied_canonical = denied.canonicalize().expect("canonicalize");
let mut deny_paths = vec![denied_canonical];
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(&denied, AccessMode::Read).expect("grant"));
let overrides = vec![denied];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains("allow file-read-data"),
"should emit read rule for read-only grant, got: {}",
rules
);
assert!(
!rules.contains("allow file-write*"),
"must NOT emit write rule for read-only grant, got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_deny_overrides_respects_write_only_grant() {
let dir = tempfile::tempdir().expect("tmpdir");
let denied = dir.path().join("writeonly");
std::fs::create_dir_all(&denied).expect("mkdir");
let denied_canonical = denied.canonicalize().expect("canonicalize");
let mut deny_paths = vec![denied_canonical];
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(&denied, AccessMode::Write).expect("grant"));
let overrides = vec![denied];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
let rules = caps.platform_rules().join("\n");
assert!(
!rules.contains("allow file-read-data"),
"must NOT emit read rule for write-only grant, got: {}",
rules
);
assert!(
rules.contains("allow file-write*"),
"should emit write rule for write-only grant, got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_apply_deny_overrides_merges_complementary_read_write_grants() {
let dir = tempfile::tempdir().expect("tmpdir");
let denied = dir.path().join("merged_rw");
std::fs::create_dir_all(&denied).expect("mkdir");
let denied_canonical = denied.canonicalize().expect("canonicalize");
let mut deny_paths = vec![denied_canonical];
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(&denied, AccessMode::Read).expect("read grant"));
caps.add_fs(FsCapability::new_dir(&denied, AccessMode::Write).expect("write grant"));
let overrides = vec![denied];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains("allow file-read-data"),
"should emit read rule from merged Read+Write grants, got: {}",
rules
);
assert!(
rules.contains("allow file-write*"),
"should emit write rule from merged Read+Write grants, got: {}",
rules
);
}
#[test]
fn test_apply_deny_overrides_does_not_remove_broader_deny() {
let dir = tempfile::tempdir().expect("tmpdir");
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).expect("mkdir sub");
let dir_canonical = dir.path().canonicalize().expect("canonicalize dir");
let sub_canonical = sub.canonicalize().expect("canonicalize sub");
let mut deny_paths = vec![dir_canonical.clone(), sub_canonical.clone()];
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(&sub, AccessMode::ReadWrite).expect("grant sub"));
let overrides = vec![sub.clone()];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
assert_eq!(deny_paths.len(), 1);
assert_eq!(deny_paths[0], dir_canonical);
}
#[test]
fn test_apply_deny_overrides_rejects_missing_grant() {
let dir = tempfile::tempdir().expect("tmpdir");
let denied = dir.path().join("denied");
std::fs::create_dir_all(&denied).expect("mkdir denied");
let denied_canonical = denied.canonicalize().expect("canonicalize");
let mut deny_paths = vec![denied_canonical];
let mut caps = CapabilitySet::new();
let overrides = vec![denied];
let result = apply_deny_overrides(&overrides, &mut deny_paths, &mut caps);
assert!(result.is_err());
let err = result.expect_err("expected error");
assert!(
err.to_string().contains("no matching grant"),
"error should mention missing grant, got: {}",
err
);
}
#[test]
fn test_apply_deny_overrides_rejects_group_sourced_grant() {
let dir = tempfile::tempdir().expect("tmpdir");
let denied = dir.path().join("group_granted");
std::fs::create_dir_all(&denied).expect("mkdir");
let denied_canonical = denied.canonicalize().expect("canonicalize");
let mut deny_paths = vec![denied_canonical];
let mut caps = CapabilitySet::new();
let mut cap = FsCapability::new_dir(dir.path(), AccessMode::ReadWrite).expect("grant");
cap.source = CapabilitySource::Group("system_write".to_string());
caps.add_fs(cap);
let overrides = vec![denied];
let result = apply_deny_overrides(&overrides, &mut deny_paths, &mut caps);
assert!(result.is_err());
let err = result.expect_err("expected error");
assert!(
err.to_string().contains("no matching grant"),
"group grant should not satisfy override_deny, got: {}",
err
);
}
#[test]
fn test_apply_deny_overrides_removes_symlink_and_target_deny_paths() {
let dir = tempfile::tempdir().expect("tmpdir");
let target = dir.path().join("real_dir");
std::fs::create_dir_all(&target).expect("mkdir target");
let link = dir.path().join("link_dir");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let target_canonical = target.canonicalize().expect("canonicalize target");
let link_expanded = link.clone();
let mut deny_paths = vec![link_expanded.clone(), target_canonical.clone()];
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability::new_dir(&target, AccessMode::ReadWrite).expect("grant"));
let overrides = vec![link];
apply_deny_overrides(&overrides, &mut deny_paths, &mut caps).expect("should succeed");
assert!(
deny_paths.is_empty(),
"both symlink and target deny paths should be removed, remaining: {:?}",
deny_paths
);
}
#[test]
fn test_deny_access_skips_nix_store_canonical_on_linux() {
let dir = tempfile::tempdir().expect("create tempdir");
let target = dir.path().join("real_file");
std::fs::write(&target, "content").expect("write target");
let link = dir.path().join("link_file");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let mut caps = CapabilitySet::new();
let mut deny_paths = Vec::new();
let link_str = link.to_str().expect("valid utf8");
add_deny_access_rules(link_str, &mut caps, &mut deny_paths).expect("add deny rules");
let link_canonical = link.canonicalize().expect("canonicalize");
assert!(
!should_skip_resolved_deny_target(&link_canonical),
"precondition: tempdir canonical is not a nix store path"
);
assert!(
deny_paths.contains(&link),
"deny_paths must contain the symlink path"
);
assert!(
deny_paths.contains(&link_canonical),
"non-nix-store canonical should still be added to deny_paths"
);
}
#[test]
fn test_nix_runtime_group_includes_nix_store() {
let json = crate::config::embedded::embedded_policy_json();
let policy = load_policy(json).expect("parse policy.json");
let group = policy
.groups
.get("nix_runtime")
.expect("nix_runtime group must exist");
let read_paths = &group
.allow
.as_ref()
.expect("nix_runtime must have allow block")
.read;
assert!(
read_paths.contains(&"/nix/store".to_string()),
"nix_runtime group must include /nix/store for NixOS compatibility"
);
}
}