Skip to main content

agentshield/fix/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::Result;
4use crate::rules::Finding;
5
6pub mod dependencies;
7pub mod deserializer;
8
9/// Summary of a fix applied to a single line or AST node.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct AppliedFix {
12    pub rule_id: String,
13    pub description: String,
14    pub line_number: usize,
15}
16
17/// A patch containing original and modified file contents along with applied fixes.
18#[derive(Debug, Clone)]
19pub struct FilePatch {
20    pub file_path: PathBuf,
21    pub original_content: String,
22    pub modified_content: String,
23    pub applied_fixes: Vec<AppliedFix>,
24}
25
26impl FilePatch {
27    pub fn has_changes(&self) -> bool {
28        self.original_content != self.modified_content
29    }
30
31    /// Write modified content to disk.
32    pub fn write_to_disk(&self) -> std::io::Result<()> {
33        std::fs::write(&self.file_path, &self.modified_content)
34    }
35
36    /// Generate a unified diff representation of the patch.
37    pub fn render_diff(&self) -> String {
38        generate_unified_diff(
39            &self.file_path,
40            &self.original_content,
41            &self.modified_content,
42        )
43    }
44}
45
46/// Core autofix orchestrator.
47#[derive(Default)]
48pub struct FixEngine;
49
50impl FixEngine {
51    pub fn new() -> Self {
52        Self
53    }
54
55    /// Generate patches for a set of findings across a project root.
56    pub fn generate_patches(
57        &self,
58        findings: &[Finding],
59        project_root: &Path,
60        filter_rules: Option<&[String]>,
61    ) -> Result<Vec<FilePatch>> {
62        let mut file_map: std::collections::HashMap<PathBuf, Vec<&Finding>> =
63            std::collections::HashMap::new();
64
65        for finding in findings {
66            if let Some(rules) = filter_rules {
67                if !rules.is_empty()
68                    && !rules
69                        .iter()
70                        .any(|r| r.eq_ignore_ascii_case(&finding.rule_id))
71                {
72                    continue;
73                }
74            }
75
76            if let Some(ref loc) = finding.location {
77                let resolved_path = if loc.file.exists() {
78                    loc.file.clone()
79                } else if project_root.join(&loc.file).exists() {
80                    project_root.join(&loc.file)
81                } else if let Some(file_name) = loc.file.file_name() {
82                    if project_root.join(file_name).exists() {
83                        project_root.join(file_name)
84                    } else {
85                        continue;
86                    }
87                } else {
88                    continue;
89                };
90
91                let abs_path = resolved_path.canonicalize().unwrap_or(resolved_path);
92                file_map.entry(abs_path).or_default().push(finding);
93            }
94        }
95
96        let mut patches = Vec::new();
97
98        for (file_path, file_findings) in file_map {
99            if !file_path.exists() || !file_path.is_file() {
100                continue;
101            }
102
103            let content = match std::fs::read_to_string(&file_path) {
104                Ok(c) => c,
105                Err(_) => continue,
106            };
107
108            let mut current_content = content.clone();
109            let mut applied_fixes = Vec::new();
110
111            // Run deserializer fixer on SHIELD-016 findings
112            if file_findings.iter().any(|f| f.rule_id == "SHIELD-016") {
113                if let Some(patched) =
114                    deserializer::fix_unsafe_deserializers(&current_content, &file_path)
115                {
116                    current_content = patched.content;
117                    applied_fixes.extend(patched.fixes);
118                }
119            }
120
121            // Run dependency pinning fixer on SHIELD-009 findings
122            if file_findings.iter().any(|f| f.rule_id == "SHIELD-009") {
123                if let Some(patched) =
124                    dependencies::fix_unpinned_dependencies(&current_content, &file_path)
125                {
126                    current_content = patched.content;
127                    applied_fixes.extend(patched.fixes);
128                }
129            }
130
131            if current_content != content {
132                patches.push(FilePatch {
133                    file_path,
134                    original_content: content,
135                    modified_content: current_content,
136                    applied_fixes,
137                });
138            }
139        }
140
141        Ok(patches)
142    }
143}
144
145/// Helper struct returned by individual fixer modules.
146#[derive(Debug, Clone)]
147pub struct FixOutput {
148    pub content: String,
149    pub fixes: Vec<AppliedFix>,
150}
151
152/// Simple line-based unified diff generator.
153pub fn generate_unified_diff(file_path: &Path, original: &str, modified: &str) -> String {
154    let orig_lines: Vec<&str> = original.lines().collect();
155    let mod_lines: Vec<&str> = modified.lines().collect();
156
157    let mut diff = String::new();
158    diff.push_str(&format!("--- a/{}\n", file_path.display()));
159    diff.push_str(&format!("+++ b/{}\n", file_path.display()));
160
161    let mut i = 0;
162    let mut j = 0;
163
164    while i < orig_lines.len() || j < mod_lines.len() {
165        if i < orig_lines.len() && j < mod_lines.len() && orig_lines[i] == mod_lines[j] {
166            i += 1;
167            j += 1;
168            continue;
169        }
170
171        let start_i = i;
172        let start_j = j;
173
174        // Collect changed lines
175        let mut orig_chunk = Vec::new();
176        let mut mod_chunk = Vec::new();
177
178        while i < orig_lines.len() && (j >= mod_lines.len() || orig_lines[i] != mod_lines[j]) {
179            orig_chunk.push(orig_lines[i]);
180            i += 1;
181            if i >= orig_lines.len() || j >= mod_lines.len() || orig_lines[i] == mod_lines[j] {
182                break;
183            }
184        }
185
186        while j < mod_lines.len() && (i >= orig_lines.len() || orig_lines[i] != mod_lines[j]) {
187            mod_chunk.push(mod_lines[j]);
188            j += 1;
189            if i < orig_lines.len() && j < mod_lines.len() && orig_lines[i] == mod_lines[j] {
190                break;
191            }
192        }
193
194        diff.push_str(&format!(
195            "@@ -{},{} +{},{} @@\n",
196            start_i + 1,
197            orig_chunk.len(),
198            start_j + 1,
199            mod_chunk.len()
200        ));
201
202        for line in orig_chunk {
203            diff.push_str(&format!("-{line}\n"));
204        }
205        for line in mod_chunk {
206            diff.push_str(&format!("+{line}\n"));
207        }
208    }
209
210    diff
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_generate_unified_diff() {
219        let orig = "line 1\nline 2\nline 3\n";
220        let modified = "line 1\nline 2 modified\nline 3\n";
221        let diff = generate_unified_diff(Path::new("test.txt"), orig, modified);
222        assert!(diff.contains("--- a/test.txt"));
223        assert!(diff.contains("+++ b/test.txt"));
224        assert!(diff.contains("-line 2"));
225        assert!(diff.contains("+line 2 modified"));
226    }
227}