use nono::{CapabilitySet, NonoError, Result};
use std::path::{Path, PathBuf};
pub struct ProtectedRoots {
roots: Vec<PathBuf>,
}
impl ProtectedRoots {
pub fn from_defaults() -> Result<Self> {
let home = dirs::home_dir().ok_or(NonoError::HomeNotFound)?;
let state_root = resolve_path(&home.join(".nono"));
Ok(Self {
roots: vec![state_root],
})
}
pub fn as_paths(&self) -> &[PathBuf] {
&self.roots
}
}
pub fn validate_caps_against_protected_roots(
caps: &CapabilitySet,
protected_roots: &[PathBuf],
allow_parent_of_protected: bool,
) -> Result<()> {
for cap in caps.fs_capabilities() {
validate_requested_path_against_protected_roots(
&cap.resolved,
cap.is_file,
&cap.source.to_string(),
protected_roots,
allow_parent_of_protected,
)?;
}
Ok(())
}
pub fn validate_requested_path_against_protected_roots(
path: &Path,
is_file: bool,
source: &str,
protected_roots: &[PathBuf],
allow_parent_of_protected: bool,
) -> Result<()> {
let requested_path = resolve_path(path);
let resolved_roots: Vec<PathBuf> = protected_roots.iter().map(|p| resolve_path(p)).collect();
for protected_root in &resolved_roots {
let inside_protected = requested_path.starts_with(protected_root);
let parent_of_protected = !is_file && protected_root.starts_with(&requested_path);
if inside_protected {
return Err(NonoError::SandboxInit(format!(
"Refusing to grant '{}' (source: {}) because it overlaps protected nono state root '{}'.",
requested_path.display(),
source,
protected_root.display(),
)));
}
if parent_of_protected && !(cfg!(target_os = "macos") && allow_parent_of_protected) {
return Err(NonoError::SandboxInit(format!(
"Refusing to grant '{}' (source: {}) because it overlaps protected nono state root '{}'.",
requested_path.display(),
source,
protected_root.display(),
)));
}
}
Ok(())
}
#[must_use]
pub fn overlapping_protected_root(
path: &Path,
is_file: bool,
protected_roots: &[PathBuf],
) -> Option<PathBuf> {
let requested_path = resolve_path(path);
let resolved_roots: Vec<PathBuf> = protected_roots.iter().map(|p| resolve_path(p)).collect();
for protected_root in &resolved_roots {
let inside_protected = requested_path.starts_with(protected_root);
if inside_protected {
return Some(protected_root.clone());
}
let parent_of_protected = !is_file && protected_root.starts_with(&requested_path);
if parent_of_protected && !cfg!(target_os = "macos") {
return Some(protected_root.clone());
}
}
None
}
pub(crate) fn emit_protected_root_deny_rules(
protected_roots: &[PathBuf],
caps: &mut CapabilitySet,
) -> Result<()> {
if !cfg!(target_os = "macos") {
return Ok(());
}
for root in protected_roots {
let resolved = resolve_path(root);
emit_deny_rules_for_path(&resolved, caps)?;
if let Ok(canonical) = resolved.canonicalize() {
if canonical != resolved {
emit_deny_rules_for_path(&canonical, caps)?;
}
}
}
Ok(())
}
#[cfg(target_os = "macos")]
fn emit_deny_rules_for_path(path: &Path, caps: &mut CapabilitySet) -> Result<()> {
let escaped = crate::policy::escape_seatbelt_path(crate::policy::path_to_utf8(path)?)?;
let filter = 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))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn emit_deny_rules_for_path(_path: &Path, _caps: &mut CapabilitySet) -> Result<()> {
Ok(())
}
fn resolve_path(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
let mut remaining = Vec::new();
let mut current = path.to_path_buf();
loop {
if let Ok(canonical) = current.canonicalize() {
let mut result = canonical;
for component in remaining.iter().rev() {
result = result.join(component);
}
return result;
}
match current.file_name() {
Some(name) => {
remaining.push(name.to_os_string());
if !current.pop() {
break;
}
}
None => break,
}
}
path.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use nono::{AccessMode, CapabilitySet, FsCapability};
use tempfile::TempDir;
#[test]
fn parent_directory_capability_blocked_without_opt_in() {
let tmp = TempDir::new().expect("tmpdir");
let parent = tmp.path().to_path_buf();
let protected = parent.join(".nono");
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(&parent, AccessMode::ReadWrite).expect("dir cap");
caps.add_fs(cap);
let err =
validate_caps_against_protected_roots(&caps, &[protected], false).expect_err("blocked");
assert!(
err.to_string()
.contains("overlaps protected nono state root"),
"unexpected error: {err}",
);
}
#[cfg(target_os = "macos")]
#[test]
fn parent_directory_capability_allowed_with_opt_in_on_macos() {
let tmp = TempDir::new().expect("tmpdir");
let parent = tmp.path().to_path_buf();
let protected = parent.join(".nono");
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(&parent, AccessMode::ReadWrite).expect("dir cap");
caps.add_fs(cap);
validate_caps_against_protected_roots(&caps, &[protected], true)
.expect("allowed on macOS with opt-in");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn parent_directory_capability_blocked_even_with_opt_in_on_linux() {
let tmp = TempDir::new().expect("tmpdir");
let parent = tmp.path().to_path_buf();
let protected = parent.join(".nono");
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(&parent, AccessMode::ReadWrite).expect("dir cap");
caps.add_fs(cap);
let err = validate_caps_against_protected_roots(&caps, &[protected], true)
.expect_err("blocked on Linux even with opt-in");
assert!(
err.to_string()
.contains("overlaps protected nono state root"),
"unexpected error: {err}",
);
}
#[test]
fn inside_protected_root_always_blocked() {
let tmp = TempDir::new().expect("tmpdir");
let protected = tmp.path().join(".nono");
std::fs::create_dir_all(&protected).expect("mkdir");
let inside = protected.join("state.db");
std::fs::write(&inside, b"").expect("create file");
let err = validate_requested_path_against_protected_roots(
&inside,
true,
"test",
std::slice::from_ref(&protected),
false,
)
.expect_err("blocked");
assert!(
err.to_string()
.contains("overlaps protected nono state root"),
"unexpected error: {err}",
);
let subdir = protected.join("rollbacks");
std::fs::create_dir_all(&subdir).expect("mkdir");
let err = validate_requested_path_against_protected_roots(
&subdir,
false,
"test",
std::slice::from_ref(&protected),
false,
)
.expect_err("blocked");
assert!(
err.to_string()
.contains("overlaps protected nono state root"),
"unexpected error: {err}",
);
}
#[test]
fn blocks_child_directory_capability() {
let tmp = TempDir::new().expect("tmpdir");
let protected = tmp.path().join(".nono");
let child = protected.join("rollbacks");
std::fs::create_dir_all(&child).expect("mkdir");
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(&child, AccessMode::ReadWrite).expect("dir cap");
caps.add_fs(cap);
validate_caps_against_protected_roots(&caps, &[protected], false).expect_err("blocked");
}
#[test]
fn allows_unrelated_capability() {
let tmp = TempDir::new().expect("tmpdir");
let protected = tmp.path().join(".nono");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("mkdir");
let mut caps = CapabilitySet::new();
let cap = FsCapability::new_dir(&workspace, AccessMode::ReadWrite).expect("dir cap");
caps.add_fs(cap);
validate_caps_against_protected_roots(&caps, &[protected], false).expect("allowed");
}
#[test]
fn requested_path_blocks_nonexistent_child_under_protected_root() {
let tmp = TempDir::new().expect("tmpdir");
let protected = tmp.path().join(".nono");
std::fs::create_dir_all(&protected).expect("mkdir");
let child = protected.join("rollbacks").join("future-session");
let err = validate_requested_path_against_protected_roots(
&child,
false,
"CLI",
&[protected],
false,
)
.expect_err("blocked");
assert!(
err.to_string()
.contains("overlaps protected nono state root"),
"unexpected error: {err}",
);
}
#[test]
fn overlapping_protected_root_reports_match() {
let tmp = TempDir::new().expect("tmpdir");
let protected = tmp.path().join(".nono");
std::fs::create_dir_all(&protected).expect("mkdir");
let child = protected.join("rollbacks");
let overlap = overlapping_protected_root(&child, false, std::slice::from_ref(&protected));
let expected = std::fs::canonicalize(&protected).unwrap_or(protected);
assert_eq!(overlap, Some(expected));
}
#[cfg(target_os = "macos")]
#[test]
fn overlapping_protected_root_parent_not_flagged_on_macos() {
let tmp = TempDir::new().expect("tmpdir");
let parent = tmp.path().to_path_buf();
let protected = parent.join(".nono");
let overlap = overlapping_protected_root(&parent, false, std::slice::from_ref(&protected));
assert_eq!(overlap, None, "parent should not be flagged on macOS");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn overlapping_protected_root_parent_flagged_on_linux() {
let tmp = TempDir::new().expect("tmpdir");
let parent = tmp.path().to_path_buf();
let protected = parent.join(".nono");
let overlap = overlapping_protected_root(&parent, false, std::slice::from_ref(&protected));
assert!(overlap.is_some(), "parent should be flagged on Linux");
}
#[cfg(target_os = "macos")]
#[test]
fn emit_protected_root_deny_rules_adds_platform_rules() {
let tmp = TempDir::new().expect("tmpdir");
let protected = tmp.path().join(".nono");
std::fs::create_dir_all(&protected).expect("mkdir");
let mut caps = CapabilitySet::new();
emit_protected_root_deny_rules(&[protected], &mut caps).expect("emit rules");
let rules = caps.platform_rules();
assert!(!rules.is_empty(), "should have platform rules");
let joined = rules.join("\n");
assert!(
joined.contains("deny file-read-data"),
"should deny reads: {joined}"
);
assert!(
joined.contains("deny file-write*"),
"should deny writes: {joined}"
);
assert!(
joined.contains("allow file-read-metadata"),
"should allow metadata: {joined}"
);
}
}