1use crate::rules::{Diagnostic, Replacement};
7use std::collections::HashMap;
8use std::io::Write;
9use std::path::Path;
10use tempfile::NamedTempFile;
11use thiserror::Error;
12
13#[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
47fn validate_path(path: &Path, base_dir: &Path) -> Result<std::path::PathBuf, FixError> {
52 let canonical_base = base_dir.canonicalize().map_err(FixError::Io)?;
54
55 let canonical_path = if path.exists() {
57 path.canonicalize().map_err(FixError::Io)?
58 } else {
59 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 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
83fn 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 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 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 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
132pub fn apply_fixes(diagnostics: &[Diagnostic], base_dir: &Path) -> Result<usize, FixError> {
146 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 let validated_path = validate_path(path, base_dir)?;
165
166 let content = std::fs::read_to_string(&validated_path)?;
168
169 for replacement in &replacements {
171 validate_offsets(replacement, &content, path)?;
172 }
173
174 replacements.sort_by_key(|r| std::cmp::Reverse(r.start_byte));
177
178 for i in 0..replacements.len().saturating_sub(1) {
182 let current = &replacements[i];
183 let next = &replacements[i + 1];
184 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 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 let parent = validated_path.parent().unwrap_or(Path::new("."));
210 let mut temp_file = NamedTempFile::new_in(parent)?;
211
212 temp_file.write_all(result.as_bytes())?;
214 temp_file.flush()?;
215
216 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 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 } 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 let evil_path = PathBuf::from("C:\\Windows\\System32\\cmd.exe");
259
260 let result = validate_path(&evil_path, base);
261 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 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, 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, 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"; let replacement = Replacement {
311 file_path: PathBuf::from("test.rs"),
312 start_byte: 2, 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}