Skip to main content

cac_fixer/
lib.rs

1use cac_core::violation::{FixProposal, Violation};
2use regex::Regex;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum FixError {
8    #[error("io error: {0}")]
9    Io(#[from] std::io::Error),
10    #[error("no auto-fix available for rule {0}")]
11    NotFixable(String),
12}
13
14pub struct Fixer {
15    root: PathBuf,
16    dry_run: bool,
17}
18
19impl Fixer {
20    pub fn new(root: impl Into<PathBuf>, dry_run: bool) -> Self {
21        Self {
22            root: root.into(),
23            dry_run,
24        }
25    }
26
27    pub fn propose(&self, violations: &[Violation]) -> Vec<FixProposal> {
28        violations
29            .iter()
30            .filter(|v| v.auto_fixable)
31            .filter_map(|v| self.propose_for(v).ok())
32            .collect()
33    }
34
35    pub fn apply(&self, proposals: &[FixProposal]) -> Result<usize, FixError> {
36        let mut applied = 0usize;
37        for proposal in proposals {
38            if self.apply_one(proposal)? {
39                applied += 1;
40            }
41        }
42        Ok(applied)
43    }
44
45    fn propose_for(&self, violation: &Violation) -> Result<FixProposal, FixError> {
46        match violation.rule_id.as_str() {
47            id if id.starts_with("secret-") => self.propose_secret_fix(violation),
48            id if id.starts_with("gdpr-") => self.propose_gdpr_fix(violation),
49            id if id.starts_with("soc2-") => self.propose_soc2_fix(violation),
50            _ => Err(FixError::NotFixable(violation.rule_id.clone())),
51        }
52    }
53
54    fn propose_secret_fix(&self, violation: &Violation) -> Result<FixProposal, FixError> {
55        let re = Regex::new(r#"(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*['"]?[^'"\s]+['"]?"#)
56            .unwrap();
57        let fixed = re.replace(
58            &violation.snippet,
59            "${1}=std::env::var(\"${1}\").expect(\"${1} must be set\")",
60        );
61        Ok(FixProposal {
62            violation_id: format!("{}:{}", violation.file_path, violation.line),
63            file_path: violation.file_path.clone(),
64            original_snippet: violation.snippet.clone(),
65            fixed_snippet: fixed.into_owned(),
66            description: "Replace hardcoded secret with environment variable lookup".into(),
67        })
68    }
69
70    fn propose_gdpr_fix(&self, violation: &Violation) -> Result<FixProposal, FixError> {
71        let annotation = "/// @gdpr personal-data — requires lawful basis and retention policy\n";
72        Ok(FixProposal {
73            violation_id: format!("{}:{}", violation.file_path, violation.line),
74            file_path: violation.file_path.clone(),
75            original_snippet: violation.snippet.clone(),
76            fixed_snippet: format!("{annotation}{}", violation.snippet),
77            description: "Add GDPR data-classification annotation above PII field".into(),
78        })
79    }
80
81    fn propose_soc2_fix(&self, violation: &Violation) -> Result<FixProposal, FixError> {
82        Ok(FixProposal {
83            violation_id: format!("{}:{}", violation.file_path, violation.line),
84            file_path: violation.file_path.clone(),
85            original_snippet: violation.snippet.clone(),
86            fixed_snippet: format!(
87                "audit_log::record(\"sensitive_operation\", &{{ \"file\": \"{}\", \"line\": {} }});",
88                violation.file_path, violation.line
89            ),
90            description: "Insert SOC2 audit trail call for sensitive operation".into(),
91        })
92    }
93
94    fn apply_one(&self, proposal: &FixProposal) -> Result<bool, FixError> {
95        let path = self.root.join(&proposal.file_path);
96        if !path.exists() {
97            return Ok(false);
98        }
99        let content = std::fs::read_to_string(&path)?;
100        if !content.contains(&proposal.original_snippet) {
101            return Ok(false);
102        }
103        let updated = content.replace(
104            &proposal.original_snippet,
105            &proposal.fixed_snippet,
106        );
107        if self.dry_run {
108            return Ok(true);
109        }
110        std::fs::write(path, updated)?;
111        Ok(true)
112    }
113}
114
115pub fn group_by_file(violations: &[Violation]) -> Vec<(String, Vec<&Violation>)> {
116    let mut files: Vec<String> = violations
117        .iter()
118        .map(|v| v.file_path.clone())
119        .collect();
120    files.sort();
121    files.dedup();
122    files
123        .into_iter()
124        .map(|f| {
125            let items: Vec<_> = violations.iter().filter(|v| v.file_path == f).collect();
126            (f, items)
127        })
128        .collect()
129}
130
131pub fn root_path(root: &Path) -> PathBuf {
132    root.to_path_buf()
133}