Skip to main content

vfa_tui/policy/
parser.rs

1//! Policy TOML parser — loads `policies.toml` into [`PolicyConfig`].
2//!
3//! Missing file → empty config (no error, Req 11.5).
4//! Syntax errors → collect with line number, skip malformed rules (Req 25.3).
5//! Validate rules against catalog (Req 11.4).
6
7#![deny(warnings)]
8
9use std::path::Path;
10
11use serde::Deserialize;
12
13use crate::catalog::store::CatalogStore;
14use crate::error::TuiError;
15use crate::models::agent::Lifecycle;
16use crate::models::policy::{PolicyRule, PolicyRuleType, PolicyScope, Severity, Suppression};
17
18// ---------------------------------------------------------------------------
19// TOML intermediate structs
20// ---------------------------------------------------------------------------
21
22/// Top-level deserialization target for `policies.toml`.
23#[derive(Debug, Deserialize, Default)]
24struct TomlConfig {
25    #[serde(default)]
26    rule: Vec<TomlRule>,
27    #[serde(default)]
28    suppression: Vec<TomlSuppression>,
29}
30
31/// A single `[[rule]]` entry as it appears in TOML.
32#[derive(Debug, Deserialize)]
33struct TomlRule {
34    id: String,
35    #[serde(rename = "type")]
36    rule_type: String,
37    severity: String,
38    /// Scope: either the string "all" OR an inline table {team="…"} / {name_pattern="…"}.
39    scope: toml::Value,
40    description: String,
41    // Type-specific optional fields
42    asset_id: Option<String>,
43    role_id: Option<String>,
44    threshold: Option<u32>,
45    max_mutation: Option<bool>,
46    max_egress: Option<bool>,
47    max_credentials: Option<bool>,
48    min_stage: Option<String>,
49}
50
51/// A single `[[suppression]]` entry as it appears in TOML.
52#[derive(Debug, Deserialize)]
53struct TomlSuppression {
54    rule_id: String,
55    workspace: String,
56    reason: String,
57    #[serde(default)]
58    approver: String,
59    #[serde(default)]
60    expires: String,
61}
62
63// ---------------------------------------------------------------------------
64// PolicyConfig — the parsed result returned to callers
65// ---------------------------------------------------------------------------
66
67/// Fully-parsed policy configuration.
68#[derive(Debug, Default)]
69pub struct PolicyConfig {
70    /// Parsed and valid policy rules.
71    pub rules: Vec<PolicyRule>,
72    /// Parsed suppressions.
73    pub suppressions: Vec<Suppression>,
74    /// True when the file was absent; callers may display a "no policies" notice.
75    pub no_policies_file: bool,
76    /// Parse errors collected during loading (malformed rules were skipped).
77    /// Stored as error messages so PolicyConfig remains cheaply cloneable.
78    pub parse_errors: Vec<String>,
79}
80
81// ---------------------------------------------------------------------------
82// Public API
83// ---------------------------------------------------------------------------
84
85/// Load `policies.toml` from `path`.
86///
87/// - File absent → returns `PolicyConfig { no_policies_file: true, .. }`, no error.
88/// - Syntax error → returns collected errors in `parse_errors`; valid rules are kept.
89pub fn load(path: &Path) -> Result<PolicyConfig, TuiError> {
90    if !path.exists() {
91        return Ok(PolicyConfig {
92            no_policies_file: true,
93            ..Default::default()
94        });
95    }
96
97    let content = std::fs::read_to_string(path).map_err(|e| TuiError::PolicyParse {
98        path: path.display().to_string(),
99        line: None,
100        detail: e.to_string(),
101    })?;
102
103    parse_content(&content, path)
104}
105
106/// Parse TOML `content` as a policy config; `path` is used only for error messages.
107///
108/// Returns `Err` only for fatal top-level parse failures.
109/// Rule-level failures are collected in `PolicyConfig::parse_errors`.
110pub fn parse_content(content: &str, path: &Path) -> Result<PolicyConfig, TuiError> {
111    let raw: TomlConfig = match toml::from_str(content) {
112        Ok(v) => v,
113        Err(e) => {
114            // Extract 1-based line number from the span if available.
115            let line = e.span().map(|span| {
116                let before = content.get(..span.start).unwrap_or("");
117                let n = before.lines().count();
118                if n == 0 {
119                    1usize
120                } else {
121                    n
122                }
123            });
124            return Err(TuiError::PolicyParse {
125                path: path.display().to_string(),
126                line,
127                detail: e.to_string(),
128            });
129        }
130    };
131
132    let mut rules = Vec::new();
133    let mut parse_errors = Vec::new();
134
135    for toml_rule in raw.rule {
136        match convert_rule(toml_rule, path) {
137            Ok(rule) => rules.push(rule),
138            Err(e) => parse_errors.push(e.to_string()),
139        }
140    }
141
142    let suppressions: Vec<Suppression> = raw
143        .suppression
144        .into_iter()
145        .map(|s| Suppression {
146            rule_id: s.rule_id,
147            workspace: s.workspace,
148            reason: s.reason,
149            approver: s.approver,
150            expires: s.expires,
151        })
152        .collect();
153
154    Ok(PolicyConfig {
155        rules,
156        suppressions,
157        no_policies_file: false,
158        parse_errors,
159    })
160}
161
162/// Validate rules in `config` against the catalog, returning errors for references
163/// to nonexistent assets or roles (Req 11.4).
164pub fn validate_rules(config: &PolicyConfig, catalog: &CatalogStore) -> Vec<TuiError> {
165    let mut errors = Vec::new();
166    let asset_ids = catalog.all_asset_ids();
167
168    for rule in &config.rules {
169        match &rule.rule_type {
170            PolicyRuleType::RequireAsset { asset_id } if !asset_ids.contains(asset_id) => {
171                errors.push(TuiError::PolicyInvalidRule {
172                    rule: rule.id.clone(),
173                    reason: format!("references nonexistent asset '{}'", asset_id),
174                });
175            }
176            PolicyRuleType::RequireRole { role_id }
177                if !catalog.roles.contains_key(role_id.as_str()) =>
178            {
179                errors.push(TuiError::PolicyInvalidRule {
180                    rule: rule.id.clone(),
181                    reason: format!("references nonexistent role '{}'", role_id),
182                });
183            }
184            // Satisfied RequireAsset/RequireRole references, plus MaxStale,
185            // TrustBoundary, and LifecycleGate, have no catalog refs to validate.
186            _ => {}
187        }
188    }
189
190    errors
191}
192
193// ---------------------------------------------------------------------------
194// Private helpers
195// ---------------------------------------------------------------------------
196
197fn convert_severity(s: &str, rule_id: &str, path: &Path) -> Result<Severity, TuiError> {
198    match s.to_lowercase().as_str() {
199        "critical" => Ok(Severity::Critical),
200        "warning" => Ok(Severity::Warning),
201        "info" => Ok(Severity::Info),
202        other => Err(TuiError::PolicyParse {
203            path: path.display().to_string(),
204            line: None,
205            detail: format!("rule '{}': unknown severity '{}'", rule_id, other),
206        }),
207    }
208}
209
210fn convert_scope(val: &toml::Value, rule_id: &str, path: &Path) -> Result<PolicyScope, TuiError> {
211    match val {
212        toml::Value::String(s) if s == "all" => Ok(PolicyScope::All),
213        toml::Value::Table(t) => {
214            if let Some(toml::Value::String(team)) = t.get("team") {
215                Ok(PolicyScope::Team(team.clone()))
216            } else if let Some(toml::Value::String(pat)) = t.get("name_pattern") {
217                Ok(PolicyScope::NamePattern(pat.clone()))
218            } else {
219                Err(TuiError::PolicyParse {
220                    path: path.display().to_string(),
221                    line: None,
222                    detail: format!(
223                        "rule '{}': scope table must have 'team' or 'name_pattern'",
224                        rule_id
225                    ),
226                })
227            }
228        }
229        _ => Err(TuiError::PolicyParse {
230            path: path.display().to_string(),
231            line: None,
232            detail: format!(
233                "rule '{}': scope must be \"all\" or a table {{team=…}} / {{name_pattern=…}}",
234                rule_id
235            ),
236        }),
237    }
238}
239
240fn convert_lifecycle(s: &str, rule_id: &str, path: &Path) -> Result<Lifecycle, TuiError> {
241    match s.to_lowercase().as_str() {
242        "experimental" => Ok(Lifecycle::Experimental),
243        "beta" => Ok(Lifecycle::Beta),
244        "stable" => Ok(Lifecycle::Stable),
245        "deprecated" => Ok(Lifecycle::Deprecated),
246        other => Err(TuiError::PolicyParse {
247            path: path.display().to_string(),
248            line: None,
249            detail: format!("rule '{}': unknown lifecycle stage '{}'", rule_id, other),
250        }),
251    }
252}
253
254fn convert_rule(r: TomlRule, path: &Path) -> Result<PolicyRule, TuiError> {
255    let severity = convert_severity(&r.severity, &r.id, path)?;
256    let scope = convert_scope(&r.scope, &r.id, path)?;
257
258    let rule_type = match r.rule_type.as_str() {
259        "require_asset" => {
260            let asset_id = r.asset_id.ok_or_else(|| TuiError::PolicyParse {
261                path: path.display().to_string(),
262                line: None,
263                detail: format!("rule '{}': require_asset needs 'asset_id'", r.id),
264            })?;
265            PolicyRuleType::RequireAsset { asset_id }
266        }
267        "require_role" => {
268            let role_id = r.role_id.ok_or_else(|| TuiError::PolicyParse {
269                path: path.display().to_string(),
270                line: None,
271                detail: format!("rule '{}': require_role needs 'role_id'", r.id),
272            })?;
273            PolicyRuleType::RequireRole { role_id }
274        }
275        "max_stale" => {
276            let threshold = r.threshold.ok_or_else(|| TuiError::PolicyParse {
277                path: path.display().to_string(),
278                line: None,
279                detail: format!("rule '{}': max_stale needs 'threshold'", r.id),
280            })?;
281            PolicyRuleType::MaxStale { threshold }
282        }
283        "trust_boundary" => {
284            let max_mutation = r.max_mutation.unwrap_or(true);
285            let max_egress = r.max_egress.unwrap_or(true);
286            let max_credentials = r.max_credentials.unwrap_or(true);
287            PolicyRuleType::TrustBoundary {
288                max_mutation,
289                max_egress,
290                max_credentials,
291            }
292        }
293        "lifecycle_gate" => {
294            let stage_str = r.min_stage.ok_or_else(|| TuiError::PolicyParse {
295                path: path.display().to_string(),
296                line: None,
297                detail: format!("rule '{}': lifecycle_gate needs 'min_stage'", r.id),
298            })?;
299            let min_stage = convert_lifecycle(&stage_str, &r.id, path)?;
300            PolicyRuleType::LifecycleGate { min_stage }
301        }
302        other => {
303            return Err(TuiError::PolicyParse {
304                path: path.display().to_string(),
305                line: None,
306                detail: format!("rule '{}': unknown rule type '{}'", r.id, other),
307            });
308        }
309    };
310
311    Ok(PolicyRule {
312        id: r.id,
313        rule_type,
314        severity,
315        scope,
316        description: r.description,
317    })
318}
319
320// ---------------------------------------------------------------------------
321// Tests
322// ---------------------------------------------------------------------------
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::collections::HashMap;
328
329    fn path() -> &'static Path {
330        Path::new("policies.toml")
331    }
332
333    #[test]
334    fn parse_full_policy_toml() {
335        let toml = r#"
336[[rule]]
337id = "require-scanner"
338type = "require_asset"
339asset_id = "aws-iam-analyzer"
340severity = "critical"
341scope = "all"
342description = "Need scanner"
343
344[[rule]]
345id = "require-role"
346type = "require_role"
347role_id = "cloud-security-engineer"
348severity = "warning"
349scope = { team = "platform-security" }
350description = "Need role"
351
352[[rule]]
353id = "max-stale"
354type = "max_stale"
355threshold = 5
356severity = "warning"
357scope = "all"
358description = "Max stale"
359
360[[rule]]
361id = "trust-boundary"
362type = "trust_boundary"
363max_mutation = false
364max_egress = true
365max_credentials = true
366severity = "critical"
367scope = { name_pattern = "production-*" }
368description = "Trust boundary"
369
370[[rule]]
371id = "lifecycle-gate"
372type = "lifecycle_gate"
373min_stage = "stable"
374severity = "critical"
375scope = { name_pattern = "production-*" }
376description = "Lifecycle gate"
377
378[[suppression]]
379rule_id = "trust-boundary"
380workspace = "staging-infra"
381reason = "Approved"
382approver = "lead@example.com"
383expires = "2025-06-01"
384"#;
385        let cfg = parse_content(toml, path()).unwrap();
386        assert_eq!(cfg.rules.len(), 5);
387        assert_eq!(cfg.suppressions.len(), 1);
388        assert!(!cfg.no_policies_file);
389        assert!(cfg.parse_errors.is_empty());
390    }
391
392    #[test]
393    fn missing_file_gives_empty_config() {
394        let cfg = load(Path::new("/nonexistent/policies.toml")).unwrap();
395        assert!(cfg.no_policies_file);
396        assert!(cfg.rules.is_empty());
397    }
398
399    #[test]
400    fn malformed_rule_is_skipped_but_valid_rules_kept() {
401        let toml = r#"
402[[rule]]
403id = "good-rule"
404type = "max_stale"
405threshold = 3
406severity = "warning"
407scope = "all"
408description = "OK"
409
410[[rule]]
411id = "bad-rule"
412type = "require_asset"
413# missing asset_id — should be skipped
414severity = "critical"
415scope = "all"
416description = "Missing asset_id"
417"#;
418        let cfg = parse_content(toml, path()).unwrap();
419        assert_eq!(cfg.rules.len(), 1, "expected 1 valid rule");
420        assert_eq!(cfg.rules[0].id, "good-rule");
421        assert_eq!(cfg.parse_errors.len(), 1, "expected 1 parse error");
422    }
423
424    #[test]
425    fn validate_rules_catches_nonexistent_asset() {
426        let toml = r#"
427[[rule]]
428id = "bad-asset"
429type = "require_asset"
430asset_id = "nonexistent-asset-xyz"
431severity = "critical"
432scope = "all"
433description = "Bad"
434"#;
435        let cfg = parse_content(toml, path()).unwrap();
436        let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
437        let errors = validate_rules(&cfg, &store);
438        assert_eq!(errors.len(), 1);
439        assert!(
440            matches!(&errors[0], TuiError::PolicyInvalidRule { rule, .. } if rule == "bad-asset")
441        );
442    }
443
444    #[test]
445    fn validate_rules_catches_nonexistent_role() {
446        let toml = r#"
447[[rule]]
448id = "bad-role"
449type = "require_role"
450role_id = "nonexistent-role-xyz"
451severity = "warning"
452scope = "all"
453description = "Bad"
454"#;
455        let cfg = parse_content(toml, path()).unwrap();
456        let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
457        let errors = validate_rules(&cfg, &store);
458        assert_eq!(errors.len(), 1);
459    }
460
461    #[test]
462    fn invalid_rule_type_is_collected_as_error() {
463        let toml = r#"
464[[rule]]
465id = "unknown-type"
466type = "future_rule"
467severity = "info"
468scope = "all"
469description = "Future"
470"#;
471        let cfg = parse_content(toml, path()).unwrap();
472        assert!(cfg.rules.is_empty());
473        assert_eq!(cfg.parse_errors.len(), 1);
474    }
475
476    proptest::proptest! {
477        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
478
479        #[test]
480        fn parse_does_not_panic_on_arbitrary_input(s in ".*") {
481            // Must never panic — may return Ok or Err.
482            let _ = parse_content(&s, path());
483        }
484    }
485}