1use anyhow::{Context, Result};
2use base64::{engine::general_purpose, Engine as _};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6pub fn read_file(path: &str) -> Result<String> {
8 let path = normalize_path_for_read(path)?;
9
10 validate_path_for_read(&path)?;
12
13 fs::read_to_string(&path).with_context(|| format!("Failed to read file: {}", path.display()))
14}
15
16pub async fn read_file_async(path: String) -> Result<String> {
18 tokio::task::spawn_blocking(move || {
19 read_file(&path)
20 })
21 .await
22 .context("Failed to spawn blocking task for file read")?
23}
24
25pub fn is_binary_file(path: &str) -> bool {
27 let path = Path::new(path);
28 if let Some(ext) = path.extension() {
29 let ext_str = ext.to_string_lossy().to_lowercase();
30 matches!(
31 ext_str.as_str(),
32 "pdf" | "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff"
33 )
34 } else {
35 false
36 }
37}
38
39pub fn read_binary_file(path: &str) -> Result<String> {
41 let path = normalize_path_for_read(path)?;
42
43 validate_path_for_read(&path)?;
45
46 let bytes = fs::read(&path)
47 .with_context(|| format!("Failed to read binary file: {}", path.display()))?;
48
49 Ok(general_purpose::STANDARD.encode(&bytes))
50}
51
52pub fn write_file(path: &str, content: &str) -> Result<()> {
54 let path = normalize_path(path)?;
55
56 validate_path(&path)?;
58
59 if let Some(parent) = path.parent() {
61 fs::create_dir_all(parent).with_context(|| {
62 format!(
63 "Failed to create parent directories for: {}",
64 path.display()
65 )
66 })?;
67 }
68
69 if path.exists() {
71 create_timestamped_backup(&path)?;
72 }
73
74 let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
76 let temp_path = std::path::PathBuf::from(&temp_path);
77
78 fs::write(&temp_path, content).with_context(|| {
80 format!("Failed to write to temporary file: {}", temp_path.display())
81 })?;
82
83 fs::rename(&temp_path, &path).with_context(|| {
85 format!(
86 "Failed to finalize write to: {} (temp file: {})",
87 path.display(),
88 temp_path.display()
89 )
90 })?;
91
92 Ok(())
93}
94
95fn create_timestamped_backup(path: &std::path::Path) -> Result<()> {
98 let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S");
99 let backup_path = format!("{}.backup.{}", path.display(), timestamp);
100
101 fs::copy(path, &backup_path).with_context(|| {
102 format!(
103 "Failed to create backup of: {} to {}",
104 path.display(),
105 backup_path
106 )
107 })?;
108
109 Ok(())
110}
111
112pub fn edit_file(path: &str, old_string: &str, new_string: &str) -> Result<String> {
115 let path = normalize_path(path)?;
116
117 validate_path(&path)?;
119
120 let content = fs::read_to_string(&path)
122 .with_context(|| format!("Failed to read file for editing: {}", path.display()))?;
123
124 let match_count = content.matches(old_string).count();
126 if match_count == 0 {
127 anyhow::bail!(
128 "old_string not found in {}. Make sure the text matches exactly, including whitespace and indentation.",
129 path.display()
130 );
131 }
132 if match_count > 1 {
133 anyhow::bail!(
134 "old_string appears {} times in {}. It must be unique. Include more surrounding context to make it unique.",
135 match_count,
136 path.display()
137 );
138 }
139
140 let new_content = content.replacen(old_string, new_string, 1);
142
143 create_timestamped_backup(&path)?;
145
146 let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
148 let temp_path = std::path::PathBuf::from(&temp_path);
149
150 fs::write(&temp_path, &new_content).with_context(|| {
151 format!("Failed to write to temporary file: {}", temp_path.display())
152 })?;
153
154 fs::rename(&temp_path, &path).with_context(|| {
155 format!(
156 "Failed to finalize edit to: {} (temp file: {})",
157 path.display(),
158 temp_path.display()
159 )
160 })?;
161
162 let diff = generate_diff(&content, &new_content, old_string, new_string);
164 Ok(diff)
165}
166
167fn generate_diff(old_content: &str, new_content: &str, old_string: &str, new_string: &str) -> String {
169 let old_lines: Vec<&str> = old_content.lines().collect();
170 let new_lines: Vec<&str> = new_content.lines().collect();
171
172 let removed_count = old_string.lines().count();
173 let added_count = new_string.lines().count();
174
175 let prefix_len = old_content[..old_content.find(old_string).unwrap_or(0)].len();
177 let change_start_line = old_content[..prefix_len].matches('\n').count();
178
179 let context_lines = 3;
180 let diff_start = change_start_line.saturating_sub(context_lines);
181 let new_diff_end = (change_start_line + added_count + context_lines).min(new_lines.len());
182
183 let mut output = String::new();
184 output.push_str(&format!("Added {} lines, removed {} lines\n", added_count, removed_count));
185
186 for i in diff_start..change_start_line {
188 if i < old_lines.len() {
189 output.push_str(&format!("{:>4} {}\n", i + 1, old_lines[i]));
190 }
191 }
192
193 for i in 0..removed_count {
195 let line_num = change_start_line + i;
196 if line_num < old_lines.len() {
197 output.push_str(&format!("{:>4} - {}\n", line_num + 1, old_lines[line_num]));
198 }
199 }
200
201 for i in 0..added_count {
203 let line_num = change_start_line + i;
204 if line_num < new_lines.len() {
205 output.push_str(&format!("{:>4} + {}\n", line_num + 1, new_lines[line_num]));
206 }
207 }
208
209 let context_after_start = change_start_line + added_count;
211 for i in context_after_start..new_diff_end {
212 if i < new_lines.len() {
213 output.push_str(&format!("{:>4} {}\n", i + 1, new_lines[i]));
214 }
215 }
216
217 output
218}
219
220pub fn delete_file(path: &str) -> Result<()> {
222 let path = normalize_path(path)?;
223
224 validate_path(&path)?;
226
227 if path.exists() {
229 create_timestamped_backup(&path)?;
230 }
231
232 fs::remove_file(&path).with_context(|| format!("Failed to delete file: {}", path.display()))
233}
234
235pub fn create_directory(path: &str) -> Result<()> {
237 let path = normalize_path(path)?;
238
239 validate_path(&path)?;
241
242 fs::create_dir_all(&path)
243 .with_context(|| format!("Failed to create directory: {}", path.display()))
244}
245
246fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
248 let path = Path::new(path);
249
250 if path.is_absolute() {
251 Ok(path.to_path_buf())
253 } else {
254 let current_dir = std::env::current_dir()?;
256 Ok(current_dir.join(path))
257 }
258}
259
260fn normalize_path(path: &str) -> Result<PathBuf> {
262 let path = Path::new(path);
263
264 if path.is_absolute() {
265 let current_dir = std::env::current_dir()?;
267 if !path.starts_with(¤t_dir) {
268 anyhow::bail!("Access denied: path outside of project directory");
269 }
270 Ok(path.to_path_buf())
271 } else {
272 let current_dir = std::env::current_dir()?;
274 Ok(current_dir.join(path))
275 }
276}
277
278fn is_sensitive_path(path: &Path) -> bool {
284 let sensitive_dirs = [".ssh", ".aws", ".gnupg"];
286
287 let sensitive_filenames = [
289 ".npmrc",
290 ".pypirc",
291 "id_rsa",
292 "id_ed25519",
293 "id_ecdsa",
294 "id_dsa",
295 "credentials.json",
296 "secrets.yaml",
297 "secrets.yml",
298 "token.json",
299 ];
300
301 let sensitive_extensions = ["pem", "key"];
303
304 let path_str = path.to_string_lossy();
305
306 if path_str.contains(".git/config") || path_str.contains(".git\\config") {
308 return true;
309 }
310
311 for component in path.components() {
312 let name = component.as_os_str().to_string_lossy();
313
314 for dir in &sensitive_dirs {
316 if name == *dir {
317 return true;
318 }
319 }
320
321 if name == ".env" || name.starts_with(".env.") {
324 return true;
325 }
326
327 for filename in &sensitive_filenames {
329 if name == *filename {
330 return true;
331 }
332 }
333 }
334
335 if let Some(ext) = path.extension() {
337 let ext_str = ext.to_string_lossy().to_lowercase();
338 for sensitive_ext in &sensitive_extensions {
339 if ext_str == *sensitive_ext {
340 return true;
341 }
342 }
343 }
344
345 false
346}
347
348fn validate_path_for_read(path: &Path) -> Result<()> {
350 if is_sensitive_path(path) {
351 anyhow::bail!(
352 "Security error: attempted to access potentially sensitive file: {}",
353 path.display()
354 );
355 }
356 Ok(())
357}
358
359fn validate_path(path: &Path) -> Result<()> {
361 let current_dir = std::env::current_dir()?;
362
363 let canonical = if path.exists() {
366 path.canonicalize()?
367 } else {
368 let mut ancestors_to_join = Vec::new();
370 let mut current = path;
371
372 while let Some(parent) = current.parent() {
373 if let Some(name) = current.file_name() {
374 ancestors_to_join.push(name.to_os_string());
375 }
376 if parent.as_os_str().is_empty() {
377 break;
379 }
380 if parent.exists() {
381 let mut result = parent.canonicalize()?;
383 for component in ancestors_to_join.iter().rev() {
384 result = result.join(component);
385 }
386 return validate_canonical_path(&result, ¤t_dir);
387 }
388 current = parent;
389 }
390
391 let mut result = current_dir.canonicalize().unwrap_or_else(|_| current_dir.clone());
393 for component in ancestors_to_join.iter().rev() {
394 result = result.join(component);
395 }
396 result
397 };
398
399 validate_canonical_path(&canonical, ¤t_dir)
400}
401
402fn validate_canonical_path(canonical: &Path, current_dir: &Path) -> Result<()> {
404 let current_dir_canonical = current_dir.canonicalize().unwrap_or_else(|_| current_dir.to_path_buf());
406
407 if !canonical.starts_with(¤t_dir_canonical) {
409 anyhow::bail!(
410 "Security error: attempted to access path outside of project directory: {}",
411 canonical.display()
412 );
413 }
414
415 if is_sensitive_path(canonical) {
417 anyhow::bail!(
418 "Security error: attempted to access potentially sensitive file: {}",
419 canonical.display()
420 );
421 }
422
423 Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
433 fn test_read_file_valid() {
434 let result = read_file("Cargo.toml");
436 assert!(
437 result.is_ok(),
438 "Should successfully read valid file from project"
439 );
440 let content = result.unwrap();
441 assert!(
442 content.contains("[package]") || !content.is_empty(),
443 "Content should be reasonable"
444 );
445 }
446
447 #[test]
448 fn test_read_file_not_found() {
449 let result = read_file("this_file_definitely_does_not_exist_12345.txt");
450 assert!(result.is_err(), "Should fail to read non-existent file");
451 let err_msg = result.unwrap_err().to_string();
452 assert!(
453 err_msg.contains("Failed to read file"),
454 "Error message should indicate read failure, got: {}",
455 err_msg
456 );
457 }
458
459 #[test]
460 fn test_write_and_read_roundtrip() {
461 let test_path = "target/test_write_roundtrip.txt";
463 let content = "Hello, Mermaid!";
464 let result = write_file(test_path, content);
465 assert!(result.is_ok(), "Write should succeed in target/");
466
467 let read_back = read_file(test_path);
468 assert!(read_back.is_ok(), "Should read back written file");
469 assert_eq!(read_back.unwrap(), content);
470
471 let _ = fs::remove_file(test_path);
473 let _ = fs::remove_file(format!("{}.backup", test_path));
475 }
476
477 #[test]
478 fn test_delete_file_not_found() {
479 let result = delete_file("this_definitely_should_not_exist_xyz123.txt");
480 assert!(result.is_err(), "Should fail to delete non-existent file");
481 }
482
483 #[test]
484 fn test_create_directory_simple() {
485 let dir_path = "target/test_dir_creation";
486
487 let result = create_directory(dir_path);
488 assert!(result.is_ok(), "Should successfully create directory");
489
490 let full_path = Path::new(dir_path);
491 assert!(full_path.exists(), "Directory should exist");
492 assert!(full_path.is_dir(), "Should be a directory");
493
494 fs::remove_dir(dir_path).ok();
496 }
497
498 #[test]
499 fn test_create_nested_directories_all() {
500 let nested_path = "target/level1/level2/level3";
501
502 let result = create_directory(nested_path);
503 assert!(
504 result.is_ok(),
505 "Should create nested directories: {}",
506 result.unwrap_err()
507 );
508
509 let full_path = Path::new(nested_path);
510 assert!(full_path.exists(), "Nested directory should exist");
511 assert!(full_path.is_dir(), "Should be a directory");
512
513 fs::remove_dir_all("target/level1").ok();
515 }
516
517 #[test]
518 fn test_path_validation_blocks_dotenv() {
519 let result = read_file(".env");
520 assert!(result.is_err(), "Should reject .env file access");
521 let error = result.unwrap_err().to_string();
522 assert!(error.contains("Security"), "Error should mention Security: {}", error);
523 }
524
525 #[test]
526 fn test_path_validation_blocks_dotenv_variants() {
527 assert!(is_sensitive_path(Path::new("/project/.env.local")));
529 assert!(is_sensitive_path(Path::new("/project/.env.production")));
530 assert!(!is_sensitive_path(Path::new("/project/src/.environment.ts")));
532 assert!(!is_sensitive_path(Path::new("/project/src/environment.rs")));
533 }
534
535 #[test]
536 fn test_path_validation_blocks_ssh_keys() {
537 let result = read_file(".ssh/id_rsa");
538 assert!(result.is_err(), "Should reject .ssh/id_rsa access");
539 let error = result.unwrap_err().to_string();
540 assert!(error.contains("Security"), "Error should mention Security: {}", error);
541 }
542
543 #[test]
544 fn test_path_validation_blocks_aws_credentials() {
545 let result = read_file(".aws/credentials");
546 assert!(result.is_err(), "Should reject .aws/credentials access");
547 let error = result.unwrap_err().to_string();
548 assert!(error.contains("Security"), "Error should mention Security: {}", error);
549 }
550
551 #[test]
552 fn test_path_validation_blocks_new_sensitive_patterns() {
553 assert!(is_sensitive_path(Path::new("/home/user/credentials.json")));
555 assert!(is_sensitive_path(Path::new("/project/secrets.yaml")));
556 assert!(is_sensitive_path(Path::new("/project/server.pem")));
557 assert!(is_sensitive_path(Path::new("/project/private.key")));
558 assert!(is_sensitive_path(Path::new("/project/token.json")));
559 assert!(is_sensitive_path(Path::new("/home/user/.gnupg/pubring.kbx")));
560 }
561}