use super::paths::wildcard_match;
use crate::utils::paths::expand_home;
use serde_json::Value;
use std::{collections::BTreeMap, fmt, str::FromStr};
use strum_macros::{EnumString, IntoStaticStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
pub(super) enum Action {
Allow,
Ask,
Deny,
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(<&'static str>::from(*self))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RuleValue {
pub action: Action,
pub reason: Option<String>,
}
impl Default for RuleValue {
fn default() -> Self {
RuleValue::new(Action::Ask)
}
}
impl RuleValue {
pub(super) fn new(action: Action) -> Self {
RuleValue {
action,
reason: None,
}
}
pub(super) fn parse(value: &Value) -> Option<Self> {
match value {
Value::String(s) => Action::from_str(s.trim()).ok().map(RuleValue::new),
Value::Object(map) => {
let action = map
.get("action")?
.as_str()
.and_then(|s| Action::from_str(s.trim()).ok())?;
let reason = map
.get("reason")
.and_then(|r| r.as_str())
.map(str::to_string);
Some(RuleValue { action, reason })
}
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct PatternMap {
rules: Vec<(String, RuleValue)>,
invalid: Vec<String>,
}
#[derive(Debug, Clone)]
pub(super) struct Match {
pub pattern: String,
pub rule: RuleValue,
pub matched_as: String,
}
impl PatternMap {
pub(super) fn parse(value: &Value) -> (Self, Vec<String>) {
let mut rules = Vec::new();
let mut bad = Vec::new();
if let Value::Object(map) = value {
for (pattern, raw) in map {
match RuleValue::parse(raw) {
Some(rule) => rules.push((pattern.clone(), rule)),
None => bad.push(pattern.clone()),
}
}
}
(
PatternMap {
rules,
invalid: bad.clone(),
},
bad,
)
}
pub(super) fn patterns(&self) -> impl Iterator<Item = &str> {
self.rules.iter().map(|(p, _)| p.as_str())
}
pub(super) fn all_deny(&self) -> bool {
!self.rules.is_empty() && self.rules.iter().all(|(_, r)| r.action == Action::Deny)
}
pub(super) fn evaluate(&self, texts: &[String]) -> Option<Match> {
let mut hit: Option<Match> = None;
for (pattern, rule) in &self.rules {
let pattern_expanded = expand_home(pattern);
for text in texts {
if wildcard_match(pattern, text) || wildcard_match(&pattern_expanded, text) {
hit = Some(Match {
pattern: pattern.clone(),
rule: rule.clone(),
matched_as: text.clone(),
});
break;
}
}
}
hit
}
pub(super) fn set(&mut self, pattern: &str, rule: RuleValue) {
match self.rules.iter_mut().find(|(p, _)| p == pattern) {
Some((_, slot)) => *slot = rule,
None => self.rules.push((pattern.to_string(), rule)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Surface {
CatchAll(RuleValue),
Patterns(PatternMap),
}
impl Surface {
pub(super) fn patterns(&self) -> Vec<&str> {
match self {
Surface::CatchAll(_) => vec!["*"],
Surface::Patterns(map) => map.patterns().collect(),
}
}
pub(super) fn all_deny(&self) -> bool {
match self {
Surface::CatchAll(rule) => rule.action == Action::Deny,
Surface::Patterns(map) => map.all_deny(),
}
}
pub(super) fn evaluate(&self, texts: &[String]) -> Option<Match> {
match self {
Surface::CatchAll(rule) => texts.first().map(|t| Match {
pattern: "*".to_string(),
rule: rule.clone(),
matched_as: t.clone(),
}),
Surface::Patterns(map) => map.evaluate(texts),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct Decision {
pub action: Action,
pub surface: String,
pub pattern: String,
pub reason: Option<String>,
pub matched_as: Option<String>,
}
impl Decision {
pub(super) fn from_match(surface: &str, hit: Match) -> Self {
let rewritten = hit.matched_as != hit.pattern;
Decision {
action: hit.rule.action,
surface: surface.to_string(),
pattern: hit.pattern,
reason: hit.rule.reason,
matched_as: rewritten.then_some(hit.matched_as),
}
}
pub(super) fn tighten(self, other: Option<Decision>) -> Self {
match other {
None => self,
Some(o) if o.action > self.action => o,
Some(_) => self,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Direction {
Read,
Write,
}
#[derive(Debug, Clone, Default)]
pub(super) struct Policy {
pub universal: RuleValue,
pub surfaces: BTreeMap<String, Surface>,
pub path: Option<PatternMap>,
pub path_read: Option<PatternMap>,
pub path_write: Option<PatternMap>,
pub external_directory: Option<PatternMap>,
pub external_directory_read: Option<PatternMap>,
pub external_directory_write: Option<PatternMap>,
}
impl Policy {
pub(super) fn path_decision(&self, forms: &[String], dir: Direction) -> Option<Decision> {
let directional = match dir {
Direction::Read => self.path_read.as_ref(),
Direction::Write => self.path_write.as_ref(),
};
let mut out = directional
.and_then(|m| m.evaluate(forms))
.map(|hit| Decision::from_match("path", hit));
if let Some(hit) = self.path.as_ref().and_then(|m| m.evaluate(forms)) {
let decision = Decision::from_match("path", hit);
out = Some(match out {
Some(prev) => prev.tighten(Some(decision)),
None => decision,
});
}
out
}
pub(super) fn external_decision(&self, forms: &[String], dir: Direction) -> Option<Decision> {
let directional = match dir {
Direction::Read => self.external_directory_read.as_ref(),
Direction::Write => self.external_directory_write.as_ref(),
};
let mut out = directional
.and_then(|m| m.evaluate(forms))
.map(|hit| Decision::from_match("external_directory", hit));
if let Some(hit) = self
.external_directory
.as_ref()
.and_then(|m| m.evaluate(forms))
{
let decision = Decision::from_match("external_directory", hit);
out = Some(match out {
Some(prev) => prev.tighten(Some(decision)),
None => decision,
});
}
out
}
pub(super) fn tool_decision(&self, tool: &str, texts: &[String]) -> Option<Decision> {
let surface = self.surfaces.get(tool)?;
surface
.evaluate(texts)
.map(|hit| Decision::from_match(tool, hit))
}
pub(super) fn bash_decision(&self, texts: &[String]) -> Option<Decision> {
let surface = self.surfaces.get("bash")?;
surface
.evaluate(texts)
.map(|hit| Decision::from_match("bash", hit))
}
pub(super) fn surface_all_deny(&self, tool: &str) -> bool {
self.surfaces.get(tool).is_some_and(Surface::all_deny)
}
pub(super) fn has_surface(&self, tool: &str) -> bool {
self.surfaces.contains_key(tool)
}
pub(super) fn has_external(&self) -> bool {
self.external_directory.is_some()
|| self.external_directory_read.is_some()
|| self.external_directory_write.is_some()
}
pub(super) fn has_path(&self) -> bool {
self.path.is_some() || self.path_read.is_some() || self.path_write.is_some()
}
pub(super) fn surface_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.surfaces.keys().cloned().collect();
for fixed in ["path", "external_directory"] {
if !names.iter().any(|n| n == fixed) {
names.push(fixed.to_string());
}
}
names.sort();
names
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(value: Value) -> PatternMap {
PatternMap::parse(&value).0
}
#[test]
fn action_parsing() {
assert_eq!(Action::from_str("allow"), Ok(Action::Allow));
assert_eq!(Action::from_str("ask"), Ok(Action::Ask));
assert_eq!(Action::from_str("deny"), Ok(Action::Deny));
assert!(Action::from_str("nope").is_err());
assert!(Action::Deny > Action::Ask);
assert!(Action::Ask > Action::Allow);
}
#[test]
fn rule_value_object_form() {
let rule = RuleValue::parse(&json!({"action": "deny", "reason": "use pnpm"}))
.expect("object form");
assert_eq!(rule.action, Action::Deny);
assert_eq!(rule.reason.as_deref(), Some("use pnpm"));
assert!(RuleValue::parse(&json!({"reason": "x"})).is_none());
assert!(RuleValue::parse(&json!(3)).is_none());
}
#[test]
fn last_match_wins() {
let m = map(json!({"*": "allow", "*.env": "deny"}));
let hit = m.evaluate(&["src/.env".to_string()]).expect("hit");
assert_eq!(hit.rule.action, Action::Deny);
assert_eq!(hit.pattern, "*.env");
let m = map(json!({"*.env": "deny", "*": "allow"}));
let hit = m.evaluate(&["src/.env".to_string()]).expect("hit");
assert_eq!(hit.rule.action, Action::Allow);
assert_eq!(hit.pattern, "*");
}
#[test]
fn no_match_is_none() {
let m = map(json!({"*.env": "deny"}));
assert!(m.evaluate(&["src/a.rs".to_string()]).is_none());
}
#[test]
fn any_spelling_matches() {
let m = map(json!({"/work/proj/*": "deny"}));
let forms = vec!["src/a.rs".to_string(), "/work/proj/src/a.rs".to_string()];
let hit = m.evaluate(&forms).expect("hit on absolute spelling");
assert_eq!(hit.matched_as, "/work/proj/src/a.rs");
}
#[test]
fn surface_catch_all_and_all_deny() {
let s = Surface::CatchAll(RuleValue::new(Action::Deny));
assert!(s.all_deny());
let s = Surface::Patterns(map(json!({"*": "deny", "git *": "ask"})));
assert!(!s.all_deny());
}
#[test]
fn directional_tables_compose() {
let policy = Policy {
universal: RuleValue::new(Action::Ask),
path: Some(map(json!({"*": "allow"}))),
path_write: Some(map(json!({"*": "deny"}))),
..Policy::default()
};
let forms = vec!["src/a.rs".to_string()];
let read = policy.path_decision(&forms, Direction::Read).expect("read");
assert_eq!(read.action, Action::Allow);
let write = policy
.path_decision(&forms, Direction::Write)
.expect("write");
assert_eq!(write.action, Action::Deny);
}
#[test]
fn decision_tighten_prefers_stricter() {
let deny = Decision {
action: Action::Deny,
surface: "path".into(),
pattern: "*.env".into(),
reason: None,
matched_as: None,
};
let allow = Decision {
action: Action::Allow,
..deny.clone()
};
assert_eq!(
allow.clone().tighten(Some(deny.clone())).action,
Action::Deny
);
assert_eq!(deny.clone().tighten(Some(allow)).action, Action::Deny);
assert_eq!(deny.action, Action::Deny);
}
#[test]
fn set_replaces_same_pattern() {
let mut m = map(json!({"rm *": "ask"}));
m.set("rm *", RuleValue::new(Action::Allow));
let hit = m.evaluate(&["rm -rf x".to_string()]).expect("hit");
assert_eq!(hit.rule.action, Action::Allow);
assert_eq!(m.patterns().count(), 1);
}
}