use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Policy {
#[serde(default)]
pub allowed_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub enforce_paths: bool,
}
impl Policy {
pub fn permits(&self, touched: &[String]) -> bool {
if !self.enforce_paths || self.allowed_paths.is_empty() {
return true;
}
touched
.iter()
.all(|p| self.allowed_paths.iter().any(|a| within(p, a)))
}
}
fn within(path: &str, allowed: &str) -> bool {
let trimmed = allowed.trim_end_matches('/');
if trimmed.is_empty() {
return allowed.is_empty();
}
path == trimmed
|| path
.strip_prefix(trimmed)
.is_some_and(|rest| rest.starts_with('/'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unenforced_policy_permits_everything() {
let policy = Policy::default();
assert!(policy.permits(&["anywhere/at/all.rs".to_string()]));
}
#[test]
fn enforcement_confines_writes_to_declared_paths() {
let policy = Policy {
allowed_paths: vec!["crates/".to_string()],
enforce_paths: true,
..Policy::default()
};
assert!(policy.permits(&["crates/ostraka-core/src/lib.rs".to_string()]));
assert!(!policy.permits(&[".github/workflows/release.yml".to_string()]));
}
#[test]
fn a_declared_path_is_matched_by_component_not_by_prefix() {
let policy = Policy {
allowed_paths: vec!["src".to_string()],
enforce_paths: true,
..Policy::default()
};
assert!(policy.permits(&["src/lib.rs".to_string()]));
assert!(policy.permits(&["src".to_string()]));
assert!(
!policy.permits(&["src-other/secrets.rs".to_string()]),
"a sibling that shares a prefix was treated as inside"
);
assert!(!policy.permits(&["srcfile.rs".to_string()]));
let slashed = Policy {
allowed_paths: vec!["docs/".to_string()],
..policy
};
assert!(slashed.permits(&["docs/guide.md".to_string()]));
assert!(!slashed.permits(&["docsite/index.html".to_string()]));
}
#[test]
fn a_root_entry_is_not_a_wildcard_but_an_empty_one_still_is() {
let rooted = Policy {
allowed_paths: vec!["/".to_string()],
enforce_paths: true,
..Policy::default()
};
assert!(!rooted.permits(&["src/lib.rs".to_string()]));
let doubled = Policy {
allowed_paths: vec!["//".to_string()],
..rooted.clone()
};
assert!(!doubled.permits(&["README.md".to_string()]));
let empty = Policy {
allowed_paths: vec![String::new()],
..rooted
};
assert!(empty.permits(&["anywhere/at/all.rs".to_string()]));
}
}