cargo_perf/fix/
mod.rs

1//! Auto-fix functionality for applying suggested code changes.
2//!
3//! This module provides safe, atomic file modifications with proper
4//! path validation to prevent directory traversal attacks.
5
6use crate::rules::{Diagnostic, Replacement};
7use std::collections::HashMap;
8use std::io::Write;
9use std::path::Path;
10use tempfile::NamedTempFile;
11use thiserror::Error;
12
13/// Errors that can occur during fix application
14#[derive(Debug, Error)]
15pub enum FixError {
16    #[error("path traversal attempt: {path} is outside base directory {base}")]
17    PathTraversal { path: String, base: String },
18
19    #[error("invalid byte offset {offset} for file of length {len} in {path}")]
20    InvalidOffset {
21        path: String,
22        offset: usize,
23        len: usize,
24    },
25
26    #[error("byte offset {offset} is not on a UTF-8 character boundary in {path}")]
27    InvalidUtf8Boundary { path: String, offset: usize },
28
29    #[error("start_byte {start} is greater than end_byte {end} in {path}")]
30    InvalidRange {
31        path: String,
32        start: usize,
33        end: usize,
34    },
35
36    #[error("overlapping replacements at bytes {first_end} and {second_start} in {path}")]
37    OverlappingReplacements {
38        path: String,
39        first_end: usize,
40        second_start: usize,
41    },
42
43    #[error("I/O error: {0}")]
44    Io(#[from] std::io::Error),
45}
46
47/// Validate that a file path is within the allowed base directory.
48///
49/// This prevents path traversal attacks where a malicious diagnostic
50/// could attempt to write to files outside the project directory.
51fn validate_path(path: &Path, base_dir: &Path) -> Result<std::path::PathBuf, FixError> {
52    // Canonicalize the base directory
53    let canonical_base = base_dir.canonicalize().map_err(FixError::Io)?;
54
55    // For the target path, we need to handle both existing and non-existing files
56    let canonical_path = if path.exists() {
57        path.canonicalize().map_err(FixError::Io)?
58    } else {
59        // If file doesn't exist, canonicalize the parent and append the filename
60        let parent = path.parent().ok_or_else(|| FixError::PathTraversal {
61            path: path.display().to_string(),
62            base: canonical_base.display().to_string(),
63        })?;
64        let filename = path.file_name().ok_or_else(|| FixError::PathTraversal {
65            path: path.display().to_string(),
66            base: canonical_base.display().to_string(),
67        })?;
68        let canonical_parent = parent.canonicalize().map_err(FixError::Io)?;
69        canonical_parent.join(filename)
70    };
71
72    // Verify the path is within the base directory
73    if !canonical_path.starts_with(&canonical_base) {
74        return Err(FixError::PathTraversal {
75            path: canonical_path.display().to_string(),
76            base: canonical_base.display().to_string(),
77        });
78    }
79
80    Ok(canonical_path)
81}
82
83/// Validate byte offsets for a replacement operation.
84fn validate_offsets(replacement: &Replacement, content: &str, path: &Path) -> Result<(), FixError> {
85    let path_str = path.display().to_string();
86    let len = content.len();
87
88    // Check bounds
89    if replacement.start_byte > len {
90        return Err(FixError::InvalidOffset {
91            path: path_str,
92            offset: replacement.start_byte,
93            len,
94        });
95    }
96
97    if replacement.end_byte > len {
98        return Err(FixError::InvalidOffset {
99            path: path_str,
100            offset: replacement.end_byte,
101            len,
102        });
103    }
104
105    // Check ordering
106    if replacement.start_byte > replacement.end_byte {
107        return Err(FixError::InvalidRange {
108            path: path_str,
109            start: replacement.start_byte,
110            end: replacement.end_byte,
111        });
112    }
113
114    // Check UTF-8 boundaries
115    if !content.is_char_boundary(replacement.start_byte) {
116        return Err(FixError::InvalidUtf8Boundary {
117            path: path_str,
118            offset: replacement.start_byte,
119        });
120    }
121
122    if !content.is_char_boundary(replacement.end_byte) {
123        return Err(FixError::InvalidUtf8Boundary {
124            path: path_str,
125            offset: replacement.end_byte,
126        });
127    }
128
129    Ok(())
130}
131
132/// Apply auto-fixes from diagnostics with safety checks.
133///
134/// # Arguments
135/// * `diagnostics` - The diagnostics containing fix information
136/// * `base_dir` - The base directory that all fixes must be within
137///
138/// # Safety
139/// This function validates that:
140/// - All file paths are within `base_dir` (prevents path traversal)
141/// - All byte offsets are valid and on UTF-8 boundaries
142/// - Multiple fixes to the same file are applied correctly (in reverse order)
143///
144/// Writes are performed atomically using temporary files.
145pub fn apply_fixes(diagnostics: &[Diagnostic], base_dir: &Path) -> Result<usize, FixError> {
146    // Group replacements by file path
147    let mut by_file: HashMap<&Path, Vec<&Replacement>> = HashMap::new();
148
149    for diagnostic in diagnostics {
150        if let Some(fix) = &diagnostic.fix {
151            for replacement in &fix.replacements {
152                by_file
153                    .entry(replacement.file_path.as_path())
154                    .or_default()
155                    .push(replacement);
156            }
157        }
158    }
159
160    let mut fixed = 0;
161
162    for (path, mut replacements) in by_file {
163        // Validate path is within base directory
164        let validated_path = validate_path(path, base_dir)?;
165
166        // Read file content
167        let content = std::fs::read_to_string(&validated_path)?;
168
169        // Validate all offsets before applying any
170        for replacement in &replacements {
171            validate_offsets(replacement, &content, path)?;
172        }
173
174        // Sort replacements by start_byte descending
175        // This ensures later offsets are applied first, preventing offset drift
176        replacements.sort_by_key(|r| std::cmp::Reverse(r.start_byte));
177
178        // Check for overlapping replacements (after sorting, adjacent entries are checked)
179        // Since sorted descending, replacements[i] has higher start_byte than replacements[i+1]
180        // Overlap occurs if replacements[i+1].end_byte > replacements[i].start_byte
181        for i in 0..replacements.len().saturating_sub(1) {
182            let current = &replacements[i];
183            let next = &replacements[i + 1];
184            // next has lower start_byte than current (due to descending sort)
185            // Overlap if next.end_byte > current.start_byte
186            if next.end_byte > current.start_byte {
187                return Err(FixError::OverlappingReplacements {
188                    path: path.display().to_string(),
189                    first_end: next.end_byte,
190                    second_start: current.start_byte,
191                });
192            }
193        }
194
195        // Apply all replacements
196        let mut result = content;
197        for replacement in &replacements {
198            result.replace_range(
199                replacement.start_byte..replacement.end_byte,
200                &replacement.new_text,
201            );
202            fixed += 1;
203        }
204
205        // Write atomically: write to temp file, then rename
206        // This prevents corrupted files if the process is interrupted
207        // SECURITY: Use tempfile crate to create secure temp file with random name
208        // This prevents symlink attacks on predictable temp file paths
209        let parent = validated_path.parent().unwrap_or(Path::new("."));
210        let mut temp_file = NamedTempFile::new_in(parent)?;
211
212        // Write content to temp file
213        temp_file.write_all(result.as_bytes())?;
214        temp_file.flush()?;
215
216        // Atomically rename temp file to target
217        // persist() ensures the file isn't deleted when dropped
218        temp_file
219            .persist(&validated_path)
220            .map_err(|e| FixError::Io(e.error))?;
221    }
222
223    Ok(fixed)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use std::path::PathBuf;
230    use tempfile::TempDir;
231
232    #[cfg(unix)]
233    #[test]
234    fn test_path_traversal_rejected() {
235        let temp_dir = TempDir::new().unwrap();
236        let base = temp_dir.path();
237
238        // Create a path outside the base directory
239        let evil_path = PathBuf::from("/etc/passwd");
240
241        let result = validate_path(&evil_path, base);
242        assert!(result.is_err());
243
244        if let Err(FixError::PathTraversal { .. }) = result {
245            // Expected
246        } else {
247            panic!("Expected PathTraversal error");
248        }
249    }
250
251    #[cfg(windows)]
252    #[test]
253    fn test_path_traversal_rejected() {
254        let temp_dir = TempDir::new().unwrap();
255        let base = temp_dir.path();
256
257        // Create a path outside the base directory using Windows root
258        let evil_path = PathBuf::from("C:\\Windows\\System32\\cmd.exe");
259
260        let result = validate_path(&evil_path, base);
261        // On Windows, this should either fail with PathTraversal or Io error
262        // (Io if the file doesn't exist, PathTraversal if it does)
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn test_valid_path_accepted() {
268        let temp_dir = TempDir::new().unwrap();
269        let base = temp_dir.path();
270
271        // Create a file inside the base directory
272        let valid_path = base.join("test.rs");
273        std::fs::write(&valid_path, "test").unwrap();
274
275        let result = validate_path(&valid_path, base);
276        assert!(result.is_ok());
277    }
278
279    #[test]
280    fn test_invalid_offset_rejected() {
281        let content = "hello";
282        let replacement = Replacement {
283            file_path: PathBuf::from("test.rs"),
284            start_byte: 0,
285            end_byte: 100, // Way past end of content
286            new_text: "world".to_string(),
287        };
288
289        let result = validate_offsets(&replacement, content, Path::new("test.rs"));
290        assert!(matches!(result, Err(FixError::InvalidOffset { .. })));
291    }
292
293    #[test]
294    fn test_invalid_range_rejected() {
295        let content = "hello";
296        let replacement = Replacement {
297            file_path: PathBuf::from("test.rs"),
298            start_byte: 3,
299            end_byte: 1, // start > end
300            new_text: "world".to_string(),
301        };
302
303        let result = validate_offsets(&replacement, content, Path::new("test.rs"));
304        assert!(matches!(result, Err(FixError::InvalidRange { .. })));
305    }
306
307    #[test]
308    fn test_utf8_boundary_rejected() {
309        let content = "héllo"; // 'é' is 2 bytes
310        let replacement = Replacement {
311            file_path: PathBuf::from("test.rs"),
312            start_byte: 2, // Middle of 'é'
313            end_byte: 3,
314            new_text: "a".to_string(),
315        };
316
317        let result = validate_offsets(&replacement, content, Path::new("test.rs"));
318        assert!(matches!(result, Err(FixError::InvalidUtf8Boundary { .. })));
319    }
320}