use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionVerb {
Goto,
Click,
Type,
Press,
Scroll,
Eval,
Submit,
Screenshot,
Snapshot,
Extract,
Download,
}
impl ActionVerb {
pub fn as_str(self) -> &'static str {
match self {
Self::Goto => "goto",
Self::Click => "click",
Self::Type => "type",
Self::Press => "press",
Self::Scroll => "scroll",
Self::Eval => "eval",
Self::Submit => "submit",
Self::Screenshot => "screenshot",
Self::Snapshot => "snapshot",
Self::Extract => "extract",
Self::Download => "download",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionRule {
Allow,
Deny,
Confirm,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionPolicy {
#[serde(default = "default_allow")]
pub default: ActionRule,
#[serde(default)]
pub rules: HashMap<ActionVerb, ActionRule>,
}
fn default_allow() -> ActionRule {
ActionRule::Allow
}
impl Default for ActionPolicy {
fn default() -> Self {
let mut rules = HashMap::new();
rules.insert(ActionVerb::Eval, ActionRule::Deny);
rules.insert(ActionVerb::Download, ActionRule::Confirm);
Self {
default: ActionRule::Allow,
rules,
}
}
}
impl ActionPolicy {
pub fn permissive() -> Self {
Self {
default: ActionRule::Allow,
rules: HashMap::new(),
}
}
pub fn strict() -> Self {
Self {
default: ActionRule::Deny,
rules: HashMap::new(),
}
}
pub fn with_rule(mut self, verb: ActionVerb, rule: ActionRule) -> Self {
self.rules.insert(verb, rule);
self
}
pub fn check(&self, verb: ActionVerb) -> ActionRule {
*self.rules.get(&verb).unwrap_or(&self.default)
}
pub fn is_allowed(&self, verb: ActionVerb) -> bool {
matches!(self.check(verb), ActionRule::Allow)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_denies_eval_confirms_download_allows_rest() {
let p = ActionPolicy::default();
assert_eq!(p.check(ActionVerb::Eval), ActionRule::Deny);
assert_eq!(p.check(ActionVerb::Download), ActionRule::Confirm);
assert_eq!(p.check(ActionVerb::Click), ActionRule::Allow);
assert_eq!(p.check(ActionVerb::Goto), ActionRule::Allow);
}
#[test]
fn permissive_allows_everything() {
let p = ActionPolicy::permissive();
for v in [
ActionVerb::Eval,
ActionVerb::Download,
ActionVerb::Click,
ActionVerb::Goto,
] {
assert!(p.is_allowed(v), "{v:?} should be allowed");
}
}
#[test]
fn strict_denies_everything_by_default() {
let p = ActionPolicy::strict();
assert_eq!(p.check(ActionVerb::Click), ActionRule::Deny);
assert_eq!(p.check(ActionVerb::Goto), ActionRule::Deny);
}
#[test]
fn strict_plus_explicit_allow_picks_one_verb() {
let p = ActionPolicy::strict().with_rule(ActionVerb::Click, ActionRule::Allow);
assert!(p.is_allowed(ActionVerb::Click));
assert!(!p.is_allowed(ActionVerb::Type));
}
#[test]
fn json_round_trip() {
let p = ActionPolicy::default().with_rule(ActionVerb::Goto, ActionRule::Confirm);
let s = serde_json::to_string(&p).unwrap();
let back: ActionPolicy = serde_json::from_str(&s).unwrap();
assert_eq!(back.check(ActionVerb::Eval), ActionRule::Deny);
assert_eq!(back.check(ActionVerb::Goto), ActionRule::Confirm);
assert_eq!(back.check(ActionVerb::Click), ActionRule::Allow);
}
#[test]
fn json_shape_matches_documented_example() {
let json = r#"{
"default": "allow",
"rules": {
"eval": "deny",
"download": "confirm",
"goto": "allow"
}
}"#;
let p: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(p.default, ActionRule::Allow);
assert_eq!(p.check(ActionVerb::Eval), ActionRule::Deny);
assert_eq!(p.check(ActionVerb::Download), ActionRule::Confirm);
assert_eq!(p.check(ActionVerb::Goto), ActionRule::Allow);
assert_eq!(p.check(ActionVerb::Click), ActionRule::Allow); }
#[test]
fn rule_missing_from_json_falls_back_to_default() {
let json = r#"{"default":"deny"}"#;
let p: ActionPolicy = serde_json::from_str(json).unwrap();
for v in [ActionVerb::Click, ActionVerb::Eval, ActionVerb::Goto] {
assert_eq!(p.check(v), ActionRule::Deny);
}
}
#[test]
fn omitting_default_field_uses_allow() {
let json = r#"{"rules":{"eval":"deny"}}"#;
let p: ActionPolicy = serde_json::from_str(json).unwrap();
assert_eq!(p.default, ActionRule::Allow);
assert_eq!(p.check(ActionVerb::Eval), ActionRule::Deny);
assert_eq!(p.check(ActionVerb::Click), ActionRule::Allow);
}
#[test]
fn verb_as_str_matches_serde_repr() {
assert_eq!(ActionVerb::Eval.as_str(), "eval");
assert_eq!(
serde_json::to_string(&ActionVerb::Eval).unwrap(),
"\"eval\""
);
}
}