Skip to main content

mdx_rust_core/
policy.rs

1//! Structured project policy parsing.
2//!
3//! v0.4 keeps policy intentionally simple: markdown bullets and numbered lines
4//! become versioned rules with coarse categories. The parser is deterministic so
5//! reports can explain which rule a finding appears to relate to.
6
7use crate::eval::stable_hash_hex;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct ProjectPolicy {
14    pub schema_version: String,
15    pub path: String,
16    pub hash: String,
17    pub rules: Vec<PolicyRule>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
21pub struct PolicyRule {
22    pub id: String,
23    pub line: usize,
24    pub category: PolicyCategory,
25    pub severity: PolicySeverity,
26    pub text: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
30pub enum PolicyCategory {
31    PanicSafety,
32    ErrorContext,
33    InputValidation,
34    ProcessExecution,
35    Environment,
36    UnsafeCode,
37    General,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
41pub enum PolicySeverity {
42    Low,
43    Medium,
44    High,
45}
46
47pub fn load_project_policy(
48    root: &Path,
49    policy_path: Option<&Path>,
50) -> anyhow::Result<Option<ProjectPolicy>> {
51    let Some(path) = resolve_policy_path(root, policy_path) else {
52        return Ok(None);
53    };
54    let content = std::fs::read(&path)?;
55    let text = String::from_utf8_lossy(&content);
56    Ok(Some(ProjectPolicy {
57        schema_version: "0.4".to_string(),
58        path: path.display().to_string(),
59        hash: stable_hash_hex(&content),
60        rules: parse_policy_rules(&text),
61    }))
62}
63
64fn resolve_policy_path(root: &Path, policy_path: Option<&Path>) -> Option<PathBuf> {
65    if let Some(policy_path) = policy_path {
66        return Some(if policy_path.is_absolute() {
67            policy_path.to_path_buf()
68        } else {
69            root.join(policy_path)
70        });
71    }
72
73    let default = root.join(".mdx-rust/policies.md");
74    default.exists().then_some(default)
75}
76
77fn parse_policy_rules(content: &str) -> Vec<PolicyRule> {
78    content
79        .lines()
80        .enumerate()
81        .filter_map(|(index, line)| {
82            let text = extract_rule_text(line)?;
83            let category = categorize_rule(&text);
84            let severity = severity_for_category(&category);
85            Some(PolicyRule {
86                id: format!("policy-rule-{}", index + 1),
87                line: index + 1,
88                category,
89                severity,
90                text,
91            })
92        })
93        .collect()
94}
95
96fn extract_rule_text(line: &str) -> Option<String> {
97    let trimmed = line.trim();
98    if trimmed.starts_with("- ") {
99        return normalize_rule_text(trimmed.trim_start_matches("- "));
100    }
101
102    let (prefix, rest) = trimmed.split_once(['.', ')'])?;
103    if prefix.chars().all(|ch| ch.is_ascii_digit()) {
104        let text = rest.trim();
105        if !text.is_empty() {
106            return normalize_rule_text(text);
107        }
108    }
109
110    None
111}
112
113fn normalize_rule_text(text: &str) -> Option<String> {
114    let text = text.trim();
115    if text.is_empty() || text.contains("...") {
116        return None;
117    }
118    Some(text.to_string())
119}
120
121fn categorize_rule(text: &str) -> PolicyCategory {
122    let lower = text.to_ascii_lowercase();
123    if lower.contains("unwrap") || lower.contains("expect") || lower.contains("panic") {
124        PolicyCategory::PanicSafety
125    } else if lower.contains("context") || lower.contains("error") || lower.contains("failure") {
126        PolicyCategory::ErrorContext
127    } else if lower.contains("input")
128        || lower.contains("validate")
129        || lower.contains("validation")
130        || lower.contains("request")
131    {
132        PolicyCategory::InputValidation
133    } else if lower.contains("command") || lower.contains("process") || lower.contains("shell") {
134        PolicyCategory::ProcessExecution
135    } else if lower.contains("environment") || lower.contains("env var") || lower.contains("env") {
136        PolicyCategory::Environment
137    } else if lower.contains("unsafe") {
138        PolicyCategory::UnsafeCode
139    } else {
140        PolicyCategory::General
141    }
142}
143
144fn severity_for_category(category: &PolicyCategory) -> PolicySeverity {
145    match category {
146        PolicyCategory::ProcessExecution | PolicyCategory::UnsafeCode => PolicySeverity::High,
147        PolicyCategory::PanicSafety
148        | PolicyCategory::ErrorContext
149        | PolicyCategory::InputValidation
150        | PolicyCategory::Environment => PolicySeverity::Medium,
151        PolicyCategory::General => PolicySeverity::Low,
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use tempfile::tempdir;
159
160    #[test]
161    fn policy_parser_extracts_structured_rules() {
162        let policy = parse_policy_rules(
163            r#"# Policy
164
1651. Avoid unwrap in request handlers.
166- Validate external input before use.
167- Command execution must be allowlisted.
168"#,
169        );
170
171        assert_eq!(policy.len(), 3);
172        assert_eq!(policy[0].category, PolicyCategory::PanicSafety);
173        assert_eq!(policy[1].category, PolicyCategory::InputValidation);
174        assert_eq!(policy[2].severity, PolicySeverity::High);
175    }
176
177    #[test]
178    fn load_project_policy_uses_default_policy_path() {
179        let dir = tempdir().unwrap();
180        let policy_dir = dir.path().join(".mdx-rust");
181        std::fs::create_dir_all(&policy_dir).unwrap();
182        std::fs::write(policy_dir.join("policies.md"), "- Preserve error context.").unwrap();
183
184        let policy = load_project_policy(dir.path(), None).unwrap().unwrap();
185
186        assert_eq!(policy.schema_version, "0.4");
187        assert_eq!(policy.rules[0].category, PolicyCategory::ErrorContext);
188    }
189
190    #[test]
191    fn policy_parser_ignores_placeholders() {
192        let policy = parse_policy_rules(
193            r#"- ...
1941. Never ...
1952. Validate external inputs.
196"#,
197        );
198
199        assert_eq!(policy.len(), 1);
200        assert_eq!(policy[0].category, PolicyCategory::InputValidation);
201    }
202}