Skip to main content

repl_core/
policy.rs

1use crate::error::{ReplError, Result};
2
3/// A parsed policy with validated structure.
4#[derive(Debug, Clone)]
5pub struct ParsedPolicy {
6    /// Policy name or identifier.
7    pub name: String,
8    /// Individual rules extracted from the policy text.
9    pub rules: Vec<PolicyRule>,
10}
11
12/// A single rule within a policy.
13#[derive(Debug, Clone)]
14pub struct PolicyRule {
15    /// The action this rule governs (e.g., "allow", "deny", "audit").
16    pub action: String,
17    /// The resource or scope the rule applies to.
18    pub target: String,
19    /// Optional condition expression.
20    pub condition: Option<String>,
21}
22
23/// Parse and validate a policy definition string.
24///
25/// The expected format is line-oriented:
26/// ```text
27/// policy <name>
28///   <action> <target> [when <condition>]
29///   ...
30/// end
31/// ```
32///
33/// Returns a `ParsedPolicy` on success, or a descriptive error.
34pub fn parse_policy(policy: &str) -> Result<ParsedPolicy> {
35    if policy.is_empty() {
36        return Err(ReplError::PolicyParsing("Empty policy".to_string()));
37    }
38
39    let lines: Vec<&str> = policy
40        .lines()
41        .map(|l| l.trim())
42        .filter(|l| !l.is_empty())
43        .collect();
44    if lines.is_empty() {
45        return Err(ReplError::PolicyParsing(
46            "Policy contains only whitespace".to_string(),
47        ));
48    }
49
50    // Extract policy name from first line
51    let first_line = lines[0];
52    let name = if let Some(stripped) = first_line.strip_prefix("policy") {
53        let name = stripped.trim();
54        if name.is_empty() {
55            return Err(ReplError::PolicyParsing(
56                "Policy name missing after 'policy' keyword".to_string(),
57            ));
58        }
59        name.to_string()
60    } else {
61        // If there's no "policy" keyword, treat the entire input as a
62        // single-rule implicit policy (backwards-compatible with simple strings).
63        return Ok(ParsedPolicy {
64            name: "inline".to_string(),
65            rules: vec![PolicyRule {
66                action: "apply".to_string(),
67                target: policy.to_string(),
68                condition: None,
69            }],
70        });
71    };
72
73    let mut rules = Vec::new();
74    for line in &lines[1..] {
75        if *line == "end" {
76            break;
77        }
78
79        // Skip comment lines
80        if line.starts_with('#') || line.starts_with("//") {
81            continue;
82        }
83
84        let rule = parse_rule_line(line)?;
85        rules.push(rule);
86    }
87
88    if rules.is_empty() {
89        return Err(ReplError::PolicyParsing(format!(
90            "Policy '{}' has no rules",
91            name
92        )));
93    }
94
95    Ok(ParsedPolicy { name, rules })
96}
97
98/// Parse a single rule line: `<action> <target> [when <condition>]`
99fn parse_rule_line(line: &str) -> Result<PolicyRule> {
100    // Split on "when" to extract optional condition
101    let (main_part, condition) = if let Some(idx) = line.find(" when ") {
102        let (main, cond) = line.split_at(idx);
103        (main.trim(), Some(cond[5..].trim().to_string())) // skip " when"
104    } else {
105        (line, None)
106    };
107
108    let mut parts = main_part.splitn(2, ' ');
109    let action = parts
110        .next()
111        .ok_or_else(|| ReplError::PolicyParsing(format!("Empty rule line: '{}'", line)))?
112        .to_string();
113
114    let target = parts
115        .next()
116        .ok_or_else(|| {
117            ReplError::PolicyParsing(format!(
118                "Rule '{}' missing target (expected '<action> <target>')",
119                action
120            ))
121        })?
122        .to_string();
123
124    // Validate action is a known keyword
125    let valid_actions = ["allow", "deny", "audit", "limit", "require", "apply"];
126    if !valid_actions.contains(&action.as_str()) {
127        return Err(ReplError::PolicyParsing(format!(
128            "Unknown policy action '{}'; expected one of: {}",
129            action,
130            valid_actions.join(", ")
131        )));
132    }
133
134    Ok(PolicyRule {
135        action,
136        target,
137        condition,
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn empty_policy_fails() {
147        assert!(parse_policy("").is_err());
148    }
149
150    #[test]
151    fn whitespace_only_fails() {
152        assert!(parse_policy("   \n  \n  ").is_err());
153    }
154
155    #[test]
156    fn simple_string_becomes_inline_policy() {
157        let result = parse_policy("no_external_calls").unwrap();
158        assert_eq!(result.name, "inline");
159        assert_eq!(result.rules.len(), 1);
160        assert_eq!(result.rules[0].action, "apply");
161        assert_eq!(result.rules[0].target, "no_external_calls");
162    }
163
164    #[test]
165    fn structured_policy_parses() {
166        let input = r#"
167policy hipaa_guard
168  deny network_access when patient_data
169  allow read /approved/*
170  audit all_operations
171end
172"#;
173        let result = parse_policy(input).unwrap();
174        assert_eq!(result.name, "hipaa_guard");
175        assert_eq!(result.rules.len(), 3);
176
177        assert_eq!(result.rules[0].action, "deny");
178        assert_eq!(result.rules[0].target, "network_access");
179        assert_eq!(result.rules[0].condition.as_deref(), Some("patient_data"));
180
181        assert_eq!(result.rules[1].action, "allow");
182        assert_eq!(result.rules[1].target, "read /approved/*");
183        assert!(result.rules[1].condition.is_none());
184
185        assert_eq!(result.rules[2].action, "audit");
186        assert_eq!(result.rules[2].target, "all_operations");
187    }
188
189    #[test]
190    fn missing_policy_name_fails() {
191        assert!(parse_policy("policy\n  allow all\nend").is_err());
192    }
193
194    #[test]
195    fn no_rules_fails() {
196        assert!(parse_policy("policy empty\nend").is_err());
197    }
198
199    #[test]
200    fn unknown_action_fails() {
201        let input = "policy test\n  explode everything\nend";
202        assert!(parse_policy(input).is_err());
203    }
204
205    #[test]
206    fn comments_are_skipped() {
207        let input = r#"
208policy test
209  # This is a comment
210  allow read
211  // Another comment
212  deny write
213end
214"#;
215        let result = parse_policy(input).unwrap();
216        assert_eq!(result.rules.len(), 2);
217    }
218
219    #[test]
220    fn condition_parsing() {
221        let input = "policy gate\n  limit api_calls when rate > 100\nend";
222        let result = parse_policy(input).unwrap();
223        assert_eq!(result.rules[0].condition.as_deref(), Some("rate > 100"));
224    }
225}