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 atomically.
32    pub fn write_to_disk(&self) -> std::io::Result<()> {
33        let parent = self.file_path.parent().unwrap_or_else(|| Path::new("."));
34        let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
35
36        use std::io::Write;
37        temp_file.write_all(self.modified_content.as_bytes())?;
38        temp_file.flush()?;
39
40        if let Ok(metadata) = std::fs::metadata(&self.file_path) {
41            let _ = temp_file.as_file().set_permissions(metadata.permissions());
42        }
43
44        temp_file.persist(&self.file_path).map_err(|e| e.error)?;
45        Ok(())
46    }
47
48    /// Generate a unified diff representation of the patch.
49    pub fn render_diff(&self) -> String {
50        generate_unified_diff(
51            &self.file_path,
52            &self.original_content,
53            &self.modified_content,
54        )
55    }
56}
57
58/// Core autofix orchestrator.
59#[derive(Default)]
60pub struct FixEngine;
61
62impl FixEngine {
63    pub fn new() -> Self {
64        Self
65    }
66
67    /// Generate patches for a set of findings across a project root.
68    pub fn generate_patches(
69        &self,
70        findings: &[Finding],
71        project_root: &Path,
72        filter_rules: Option<&[String]>,
73    ) -> Result<Vec<FilePatch>> {
74        let mut file_map: std::collections::HashMap<PathBuf, Vec<&Finding>> =
75            std::collections::HashMap::new();
76
77        for finding in findings {
78            if let Some(rules) = filter_rules {
79                if !rules.is_empty()
80                    && !rules
81                        .iter()
82                        .any(|r| r.eq_ignore_ascii_case(&finding.rule_id))
83                {
84                    continue;
85                }
86            }
87
88            if let Some(ref loc) = finding.location {
89                let resolved_path = if loc.file.exists() {
90                    loc.file.clone()
91                } else if project_root.join(&loc.file).exists() {
92                    project_root.join(&loc.file)
93                } else if let Some(file_name) = loc.file.file_name() {
94                    if project_root.join(file_name).exists() {
95                        project_root.join(file_name)
96                    } else {
97                        continue;
98                    }
99                } else {
100                    continue;
101                };
102
103                let abs_path = resolved_path.canonicalize().unwrap_or(resolved_path);
104                file_map.entry(abs_path).or_default().push(finding);
105            }
106        }
107
108        let mut patches = Vec::new();
109
110        for (file_path, file_findings) in file_map {
111            if !file_path.exists() || !file_path.is_file() {
112                continue;
113            }
114
115            let content = match std::fs::read_to_string(&file_path) {
116                Ok(c) => c,
117                Err(_) => continue,
118            };
119
120            let mut current_content = content.clone();
121            let mut applied_fixes = Vec::new();
122
123            // Run deserializer fixer on SHIELD-016 findings
124            if file_findings.iter().any(|f| f.rule_id == "SHIELD-016") {
125                if let Some(patched) =
126                    deserializer::fix_unsafe_deserializers(&current_content, &file_path)
127                {
128                    current_content = patched.content;
129                    applied_fixes.extend(patched.fixes);
130                }
131            }
132
133            // Run dependency pinning fixer on SHIELD-009 findings
134            if file_findings.iter().any(|f| f.rule_id == "SHIELD-009") {
135                if let Some(patched) =
136                    dependencies::fix_unpinned_dependencies(&current_content, &file_path)
137                {
138                    current_content = patched.content;
139                    applied_fixes.extend(patched.fixes);
140                }
141            }
142
143            if current_content != content {
144                patches.push(FilePatch {
145                    file_path,
146                    original_content: content,
147                    modified_content: current_content,
148                    applied_fixes,
149                });
150            }
151        }
152
153        Ok(patches)
154    }
155}
156
157/// Helper struct returned by individual fixer modules.
158#[derive(Debug, Clone)]
159pub struct FixOutput {
160    pub content: String,
161    pub fixes: Vec<AppliedFix>,
162}
163
164/// Simple line-based unified diff generator.
165pub fn generate_unified_diff(file_path: &Path, original: &str, modified: &str) -> String {
166    let orig_lines: Vec<&str> = original.lines().collect();
167    let mod_lines: Vec<&str> = modified.lines().collect();
168
169    let mut diff = String::new();
170    diff.push_str(&format!("--- a/{}\n", file_path.display()));
171    diff.push_str(&format!("+++ b/{}\n", file_path.display()));
172
173    let mut i = 0;
174    let mut j = 0;
175
176    while i < orig_lines.len() || j < mod_lines.len() {
177        if i < orig_lines.len() && j < mod_lines.len() && orig_lines[i] == mod_lines[j] {
178            i += 1;
179            j += 1;
180            continue;
181        }
182
183        let start_i = i;
184        let start_j = j;
185
186        // Collect changed lines
187        let mut orig_chunk = Vec::new();
188        let mut mod_chunk = Vec::new();
189
190        while i < orig_lines.len() && (j >= mod_lines.len() || orig_lines[i] != mod_lines[j]) {
191            orig_chunk.push(orig_lines[i]);
192            i += 1;
193            if i >= orig_lines.len() || j >= mod_lines.len() || orig_lines[i] == mod_lines[j] {
194                break;
195            }
196        }
197
198        while j < mod_lines.len() && (i >= orig_lines.len() || orig_lines[i] != mod_lines[j]) {
199            mod_chunk.push(mod_lines[j]);
200            j += 1;
201            if i < orig_lines.len() && j < mod_lines.len() && orig_lines[i] == mod_lines[j] {
202                break;
203            }
204        }
205
206        diff.push_str(&format!(
207            "@@ -{},{} +{},{} @@\n",
208            start_i + 1,
209            orig_chunk.len(),
210            start_j + 1,
211            mod_chunk.len()
212        ));
213
214        for line in orig_chunk {
215            diff.push_str(&format!("-{line}\n"));
216        }
217        for line in mod_chunk {
218            diff.push_str(&format!("+{line}\n"));
219        }
220    }
221
222    diff
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::io::Read;
229
230    #[test]
231    fn test_write_to_disk_atomic() {
232        let temp_dir = tempfile::tempdir().unwrap();
233        let file_path = temp_dir.path().join("test_atomic.txt");
234
235        std::fs::write(&file_path, "original").unwrap();
236
237        let patch = FilePatch {
238            file_path: file_path.clone(),
239            original_content: "original".into(),
240            modified_content: "modified".into(),
241            applied_fixes: vec![],
242        };
243
244        patch.write_to_disk().unwrap();
245
246        let mut content = String::new();
247        std::fs::File::open(&file_path)
248            .unwrap()
249            .read_to_string(&mut content)
250            .unwrap();
251        assert_eq!(content, "modified");
252    }
253
254    #[cfg(unix)]
255    #[test]
256    fn test_write_to_disk_preserves_permissions() {
257        use std::os::unix::fs::PermissionsExt;
258
259        let temp_dir = tempfile::tempdir().unwrap();
260        let file_path = temp_dir.path().join("test_perms.txt");
261
262        std::fs::write(&file_path, "original").unwrap();
263        let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
264        perms.set_mode(0o755);
265        std::fs::set_permissions(&file_path, perms).unwrap();
266
267        let patch = FilePatch {
268            file_path: file_path.clone(),
269            original_content: "original".into(),
270            modified_content: "modified".into(),
271            applied_fixes: vec![],
272        };
273
274        patch.write_to_disk().unwrap();
275
276        let new_perms = std::fs::metadata(&file_path).unwrap().permissions();
277        assert_eq!(new_perms.mode() & 0o777, 0o755);
278    }
279
280    #[test]
281    fn test_generate_unified_diff() {
282        let orig = "line 1\nline 2\nline 3\n";
283        let modified = "line 1\nline 2 modified\nline 3\n";
284        let diff = generate_unified_diff(Path::new("test.txt"), orig, modified);
285        assert!(diff.contains("--- a/test.txt"));
286        assert!(diff.contains("+++ b/test.txt"));
287        assert!(diff.contains("-line 2"));
288        assert!(diff.contains("+line 2 modified"));
289    }
290}