use crate::AccessMode;
use crate::FilesystemDecision;
use crate::PathPattern;
use crate::PathResolutionContext;
use crate::PathSelector;
use crate::PolicyError;
use crate::path::normal_component_count;
use cageforge_path::{contains_component_path, contains_parent_traversal, is_within, paths_equal};
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesystemPolicy {
mode: FilesystemMode,
entries: Vec<FilesystemRule>,
glob_scan_max_depth: Option<NonZeroUsize>,
protected_relative_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FilesystemMode {
Restricted,
Unrestricted,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FilesystemTarget {
Scope(PathSelector),
Glob(PathPattern),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum FilesystemTargetKey {
Scope(PathSelector),
Glob {
absolute: bool,
prefix: Option<String>,
components: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MissingPathBehavior {
Error,
Skip,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FilesystemRule {
target: FilesystemTarget,
access: AccessMode,
missing_path_behavior: MissingPathBehavior,
read_only_subpaths: Vec<PathSelector>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RuleMatch {
specificity: usize,
access: AccessMode,
}
impl MissingPathBehavior {
pub const fn most_conservative(self, other: Self) -> Self {
match (self, other) {
(Self::Error, _) | (_, Self::Error) => Self::Error,
(Self::Skip, Self::Skip) => Self::Skip,
}
}
}
impl FilesystemRule {
pub const fn new(selector: PathSelector, access: AccessMode) -> Self {
Self {
target: FilesystemTarget::Scope(selector),
access,
missing_path_behavior: MissingPathBehavior::Error,
read_only_subpaths: Vec::new(),
}
}
pub fn from_target(target: FilesystemTarget, access: AccessMode) -> Result<Self, PolicyError> {
if matches!(target, FilesystemTarget::Glob(_)) && access != AccessMode::Deny {
return Err(PolicyError::UnsupportedGlobAccess { access });
}
Ok(Self {
target,
access,
missing_path_behavior: MissingPathBehavior::Error,
read_only_subpaths: Vec::new(),
})
}
pub fn absolute_glob(
pattern: impl Into<String>,
access: AccessMode,
) -> Result<Self, PolicyError> {
Self::from_target(
FilesystemTarget::Glob(PathPattern::absolute(pattern)?),
access,
)
}
pub fn workspace_glob(
pattern: impl Into<String>,
access: AccessMode,
) -> Result<Self, PolicyError> {
Self::from_target(
FilesystemTarget::Glob(PathPattern::workspace(pattern)?),
access,
)
}
pub const fn with_missing_path_behavior(mut self, behavior: MissingPathBehavior) -> Self {
self.missing_path_behavior = behavior;
self
}
pub fn with_read_only_subpath(mut self, selector: PathSelector) -> Result<Self, PolicyError> {
if self.access != AccessMode::Write {
return Err(PolicyError::InvalidRule {
message: "read-only subpaths require a writable parent rule".to_string(),
});
}
if let FilesystemTarget::Scope(parent) = &self.target
&& selector.is_definitely_outside(parent)
{
return Err(PolicyError::InvalidRule {
message: "read-only subpath must be below the writable parent rule".to_string(),
});
}
self.read_only_subpaths.push(selector);
Ok(self)
}
pub const fn target(&self) -> &FilesystemTarget {
&self.target
}
pub const fn access(&self) -> AccessMode {
self.access
}
pub const fn missing_path_behavior(&self) -> MissingPathBehavior {
self.missing_path_behavior
}
pub fn read_only_subpaths(&self) -> &[PathSelector] {
&self.read_only_subpaths
}
fn matches_path(&self, path: &Path, context: &PathResolutionContext) -> Option<RuleMatch> {
let (specificity, target_matches) = match &self.target {
FilesystemTarget::Scope(selector) => selector
.resolve(context)
.into_iter()
.filter(|root| is_within(path, root))
.map(|root| (normal_component_count(&root), true))
.max_by_key(|(specificity, _)| *specificity)
.unwrap_or((0, false)),
FilesystemTarget::Glob(pattern) => {
(pattern.specificity(), pattern.matches(path, context))
}
};
if !target_matches {
return None;
}
let mut access = self.access;
let mut specificity = specificity;
if access == AccessMode::Write {
for subpath in &self.read_only_subpaths {
let matches = subpath
.resolve(context)
.into_iter()
.filter(|root| is_within(path, root))
.map(|root| normal_component_count(&root))
.max();
if let Some(subpath_specificity) = matches {
access = AccessMode::Read;
specificity = specificity.max(subpath_specificity);
}
}
}
Some(RuleMatch {
specificity,
access,
})
}
fn validate(&self) -> Result<(), PolicyError> {
if matches!(self.target, FilesystemTarget::Glob(_)) && self.access != AccessMode::Deny {
return Err(PolicyError::UnsupportedGlobAccess {
access: self.access,
});
}
Ok(())
}
}
impl FilesystemPolicy {
pub fn restricted(entries: impl IntoIterator<Item = FilesystemRule>) -> Self {
Self {
mode: FilesystemMode::Restricted,
entries: entries.into_iter().collect(),
glob_scan_max_depth: None,
protected_relative_paths: vec![PathBuf::from(".git")],
}
}
pub const fn unrestricted() -> Self {
Self {
mode: FilesystemMode::Unrestricted,
entries: Vec::new(),
glob_scan_max_depth: None,
protected_relative_paths: Vec::new(),
}
}
pub const fn external() -> Self {
Self {
mode: FilesystemMode::External,
entries: Vec::new(),
glob_scan_max_depth: None,
protected_relative_paths: Vec::new(),
}
}
pub fn with_glob_scan_max_depth(mut self, depth: NonZeroUsize) -> Result<Self, PolicyError> {
if self.mode != FilesystemMode::Restricted {
return Err(PolicyError::InvalidRule {
message: "glob scan depth requires a restricted filesystem policy".to_string(),
});
}
self.glob_scan_max_depth = Some(depth);
Ok(self)
}
pub const fn mode(&self) -> FilesystemMode {
self.mode
}
pub fn entries(&self) -> &[FilesystemRule] {
&self.entries
}
pub const fn glob_scan_max_depth(&self) -> Option<NonZeroUsize> {
self.glob_scan_max_depth
}
pub fn protected_relative_paths(&self) -> &[PathBuf] {
&self.protected_relative_paths
}
pub fn with_additional_protected_relative_path(
mut self,
path: impl Into<PathBuf>,
) -> Result<Self, PolicyError> {
if self.mode != FilesystemMode::Restricted {
return Err(PolicyError::InvalidRule {
message: "protected paths require a restricted filesystem policy".to_string(),
});
}
let path = validate_protected_relative_path(path.into())?;
if !self
.protected_relative_paths
.iter()
.any(|existing| paths_equal(existing, &path))
{
self.protected_relative_paths.push(path);
}
Ok(self)
}
pub fn dangerously_allow_git_write(mut self) -> Self {
self.protected_relative_paths
.retain(|path| !paths_equal(path, Path::new(".git")));
self
}
pub fn with_rule(mut self, rule: FilesystemRule) -> Result<Self, PolicyError> {
if self.mode != FilesystemMode::Restricted {
return Err(PolicyError::InvalidRule {
message: "filesystem rules require a restricted filesystem policy".to_string(),
});
}
rule.validate()?;
self.entries.push(rule);
Ok(self)
}
pub fn validate(&self) -> Result<(), PolicyError> {
if self.mode != FilesystemMode::Restricted
&& (!self.entries.is_empty()
|| self.glob_scan_max_depth.is_some()
|| !self.protected_relative_paths.is_empty())
{
return Err(PolicyError::InvalidRule {
message:
"unrestricted and external filesystem policies cannot contain local settings"
.to_string(),
});
}
for rule in &self.entries {
rule.validate()?;
if rule.access != AccessMode::Write && !rule.read_only_subpaths.is_empty() {
return Err(PolicyError::InvalidRule {
message: "read-only subpaths require a writable parent rule".to_string(),
});
}
}
for path in &self.protected_relative_paths {
validate_protected_relative_path(path.clone())?;
}
Ok(())
}
pub fn normalized(&self) -> Result<Self, PolicyError> {
self.validate()?;
if self.mode != FilesystemMode::Restricted {
return Ok(self.clone());
}
let mut entries: Vec<FilesystemRule> = Vec::with_capacity(self.entries.len());
let mut positions: HashMap<FilesystemTargetKey, usize> =
HashMap::with_capacity(self.entries.len());
for rule in &self.entries {
let key = target_key(rule.target());
if let Some(&index) = positions.get(&key) {
let existing = &mut entries[index];
existing.access = existing.access.most_restrictive(rule.access);
existing.missing_path_behavior = existing
.missing_path_behavior
.most_conservative(rule.missing_path_behavior);
for selector in &rule.read_only_subpaths {
if !existing
.read_only_subpaths
.iter()
.any(|existing| crate::path::selectors_equal(existing, selector))
{
existing.read_only_subpaths.push(selector.clone());
}
}
if existing.access != AccessMode::Write {
existing.read_only_subpaths.clear();
}
} else {
positions.insert(key, entries.len());
entries.push(rule.clone());
}
}
Ok(Self {
entries,
..self.clone()
})
}
pub fn access_for(
&self,
selector: &PathSelector,
context: &PathResolutionContext,
) -> Result<FilesystemDecision, PolicyError> {
if self.mode == FilesystemMode::External {
return Ok(FilesystemDecision::ExternallyEnforced);
}
let mut result = None;
for path in selector.resolve(context) {
let decision = self.access_for_path(&path, context)?;
result = Some(match (result, decision) {
(Some(FilesystemDecision::Deny), _) | (_, FilesystemDecision::Deny) => {
FilesystemDecision::Deny
}
(Some(FilesystemDecision::Read), _) | (_, FilesystemDecision::Read) => {
FilesystemDecision::Read
}
(Some(FilesystemDecision::Write), FilesystemDecision::Write) => {
FilesystemDecision::Write
}
(None, decision) => decision,
(Some(FilesystemDecision::ExternallyEnforced), decision) => decision,
(Some(decision), FilesystemDecision::ExternallyEnforced) => decision,
});
}
Ok(result.unwrap_or(FilesystemDecision::Deny))
}
pub fn access_for_path(
&self,
path: &Path,
context: &PathResolutionContext,
) -> Result<FilesystemDecision, PolicyError> {
if crate::path::contains_nul(path) {
return Err(PolicyError::PathContainsNul {
path: path.to_path_buf(),
});
}
if !path.is_absolute() {
return Err(PolicyError::ExpectedAbsolute {
path: path.to_path_buf(),
});
}
if contains_parent_traversal(path) {
return Err(PolicyError::ParentTraversal {
path: path.to_path_buf(),
});
}
match self.mode {
FilesystemMode::Unrestricted => Ok(FilesystemDecision::Write),
FilesystemMode::External => Ok(FilesystemDecision::ExternallyEnforced),
FilesystemMode::Restricted => {
let mut best: Option<RuleMatch> = None;
let mut writable_match = false;
for rule in &self.entries {
if let Some(candidate) = rule.matches_path(path, context) {
if candidate.access == AccessMode::Deny {
return Ok(FilesystemDecision::Deny);
}
writable_match |= candidate.access == AccessMode::Write;
best = Some(match best {
Some(current) if current.specificity > candidate.specificity => current,
Some(current) if current.specificity == candidate.specificity => {
RuleMatch {
specificity: current.specificity,
access: current.access.most_restrictive(candidate.access),
}
}
_ => candidate,
});
}
}
let access = best.map_or(AccessMode::Deny, |matched| matched.access);
if writable_match && access == AccessMode::Write && self.is_protected_path(path) {
Ok(FilesystemDecision::Read)
} else {
Ok(access.into())
}
}
}
}
fn is_protected_path(&self, path: &Path) -> bool {
self.protected_relative_paths
.iter()
.any(|protected| contains_component_path(path, protected))
}
}
fn target_key(target: &FilesystemTarget) -> FilesystemTargetKey {
match target {
FilesystemTarget::Scope(selector) => FilesystemTargetKey::Scope(selector.clone()),
FilesystemTarget::Glob(pattern) => {
let (absolute, prefix, components) = pattern.semantic_key();
FilesystemTargetKey::Glob {
absolute,
prefix,
components,
}
}
}
}
fn validate_protected_relative_path(path: PathBuf) -> Result<PathBuf, PolicyError> {
if path.as_os_str().is_empty() {
return Err(PolicyError::InvalidProtectedPath {
path,
reason: "path must not be empty".to_string(),
});
}
if crate::path::contains_nul(&path) {
return Err(PolicyError::InvalidProtectedPath {
path,
reason: "path must not contain a NUL character".to_string(),
});
}
if path.is_absolute() {
return Err(PolicyError::InvalidProtectedPath {
path,
reason: "path must be relative".to_string(),
});
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(PolicyError::InvalidProtectedPath {
path,
reason: "path must not contain parent traversal or a root".to_string(),
});
}
}
}
if normalized.as_os_str().is_empty() {
return Err(PolicyError::InvalidProtectedPath {
path,
reason: "path must name a descendant".to_string(),
});
}
Ok(normalized)
}