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. Prior revisions silently accepted
51    // any non-"policy"-prefixed text as an implicit inline rule, which meant
52    // typos or misplaced content slipped through as policies. Require the
53    // `policy <name>` header explicitly so misconfigurations fail loudly at
54    // parse time.
55    let first_line = lines[0];
56    let name = if let Some(stripped) = first_line.strip_prefix("policy") {
57        let name = stripped.trim();
58        if name.is_empty() {
59            return Err(ReplError::PolicyParsing(
60                "Policy name missing after 'policy' keyword".to_string(),
61            ));
62        }
63        name.to_string()
64    } else {
65        return Err(ReplError::PolicyParsing(format!(
66            "Policy text must begin with a 'policy <name>' header; got {:?}. \
67             Inline/implicit policies are no longer accepted.",
68            first_line
69        )));
70    };
71
72    let mut rules = Vec::new();
73    for line in &lines[1..] {
74        if *line == "end" {
75            break;
76        }
77
78        // Skip comment lines
79        if line.starts_with('#') || line.starts_with("//") {
80            continue;
81        }
82
83        let rule = parse_rule_line(line)?;
84        rules.push(rule);
85    }
86
87    if rules.is_empty() {
88        return Err(ReplError::PolicyParsing(format!(
89            "Policy '{}' has no rules",
90            name
91        )));
92    }
93
94    Ok(ParsedPolicy { name, rules })
95}
96
97/// Parse a single rule line: `<action> <target> [when <condition>]`
98fn parse_rule_line(line: &str) -> Result<PolicyRule> {
99    // Split on "when" to extract optional condition
100    let (main_part, condition) = if let Some(idx) = line.find(" when ") {
101        let (main, cond) = line.split_at(idx);
102        (main.trim(), Some(cond[5..].trim().to_string())) // skip " when"
103    } else {
104        (line, None)
105    };
106
107    let mut parts = main_part.splitn(2, ' ');
108    let action = parts
109        .next()
110        .ok_or_else(|| ReplError::PolicyParsing(format!("Empty rule line: '{}'", line)))?
111        .to_string();
112
113    let target = parts
114        .next()
115        .ok_or_else(|| {
116            ReplError::PolicyParsing(format!(
117                "Rule '{}' missing target (expected '<action> <target>')",
118                action
119            ))
120        })?
121        .to_string();
122
123    // Validate action is a known keyword
124    let valid_actions = ["allow", "deny", "audit", "limit", "require", "apply"];
125    if !valid_actions.contains(&action.as_str()) {
126        return Err(ReplError::PolicyParsing(format!(
127            "Unknown policy action '{}'; expected one of: {}",
128            action,
129            valid_actions.join(", ")
130        )));
131    }
132
133    Ok(PolicyRule {
134        action,
135        target,
136        condition,
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn empty_policy_fails() {
146        assert!(parse_policy("").is_err());
147    }
148
149    #[test]
150    fn whitespace_only_fails() {
151        assert!(parse_policy("   \n  \n  ").is_err());
152    }
153
154    #[test]
155    fn simple_string_without_policy_header_errors() {
156        // Implicit inline policies (non-"policy"-prefixed text) are rejected
157        // outright; the prior silent fallback hid configuration typos.
158        let err = parse_policy("no_external_calls").expect_err("must fail");
159        let msg = format!("{err}");
160        assert!(
161            msg.contains("must begin with a 'policy <name>' header"),
162            "got {msg}"
163        );
164    }
165
166    #[test]
167    fn structured_policy_parses() {
168        let input = r#"
169policy hipaa_guard
170  deny network_access when patient_data
171  allow read /approved/*
172  audit all_operations
173end
174"#;
175        let result = parse_policy(input).unwrap();
176        assert_eq!(result.name, "hipaa_guard");
177        assert_eq!(result.rules.len(), 3);
178
179        assert_eq!(result.rules[0].action, "deny");
180        assert_eq!(result.rules[0].target, "network_access");
181        assert_eq!(result.rules[0].condition.as_deref(), Some("patient_data"));
182
183        assert_eq!(result.rules[1].action, "allow");
184        assert_eq!(result.rules[1].target, "read /approved/*");
185        assert!(result.rules[1].condition.is_none());
186
187        assert_eq!(result.rules[2].action, "audit");
188        assert_eq!(result.rules[2].target, "all_operations");
189    }
190
191    #[test]
192    fn missing_policy_name_fails() {
193        assert!(parse_policy("policy\n  allow all\nend").is_err());
194    }
195
196    #[test]
197    fn no_rules_fails() {
198        assert!(parse_policy("policy empty\nend").is_err());
199    }
200
201    #[test]
202    fn unknown_action_fails() {
203        let input = "policy test\n  explode everything\nend";
204        assert!(parse_policy(input).is_err());
205    }
206
207    #[test]
208    fn comments_are_skipped() {
209        let input = r#"
210policy test
211  # This is a comment
212  allow read
213  // Another comment
214  deny write
215end
216"#;
217        let result = parse_policy(input).unwrap();
218        assert_eq!(result.rules.len(), 2);
219    }
220
221    #[test]
222    fn condition_parsing() {
223        let input = "policy gate\n  limit api_calls when rate > 100\nend";
224        let result = parse_policy(input).unwrap();
225        assert_eq!(result.rules[0].condition.as_deref(), Some("rate > 100"));
226    }
227}