1use anyhow::{Context, Result};
2use base64::{Engine as _, engine::general_purpose};
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 || read_file(&path))
19 .await
20 .context("Failed to spawn blocking task for file read")?
21}
22
23pub fn is_binary_file(path: &str) -> bool {
25 let path = Path::new(path);
26 if let Some(ext) = path.extension() {
27 let ext_str = ext.to_string_lossy().to_lowercase();
28 matches!(
29 ext_str.as_str(),
30 "pdf" | "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff"
31 )
32 } else {
33 false
34 }
35}
36
37pub fn read_binary_file(path: &str) -> Result<String> {
39 let path = normalize_path_for_read(path)?;
40
41 validate_path_for_read(&path)?;
43
44 let bytes = fs::read(&path)
45 .with_context(|| format!("Failed to read binary file: {}", path.display()))?;
46
47 Ok(general_purpose::STANDARD.encode(&bytes))
48}
49
50pub fn write_file(path: &str, content: &str) -> Result<()> {
52 let path = normalize_path(path)?;
53
54 validate_path(&path)?;
56
57 if let Some(parent) = path.parent() {
59 fs::create_dir_all(parent).with_context(|| {
60 format!(
61 "Failed to create parent directories for: {}",
62 path.display()
63 )
64 })?;
65 }
66
67 if path.exists() {
69 create_timestamped_backup(&path)?;
70 }
71
72 atomic_write(&path, content)
73}
74
75fn create_timestamped_backup(path: &std::path::Path) -> Result<()> {
78 let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S");
79 let backup_path = format!("{}.backup.{}", path.display(), timestamp);
80
81 fs::copy(path, &backup_path).with_context(|| {
82 format!(
83 "Failed to create backup of: {} to {}",
84 path.display(),
85 backup_path
86 )
87 })?;
88
89 Ok(())
90}
91
92fn atomic_write(path: &Path, content: &str) -> Result<()> {
95 let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
96 let temp_path = PathBuf::from(&temp_path);
97
98 fs::write(&temp_path, content)
99 .with_context(|| format!("Failed to write to temporary file: {}", temp_path.display()))?;
100
101 fs::rename(&temp_path, path).with_context(|| {
102 format!(
103 "Failed to finalize write to: {} (temp file: {})",
104 path.display(),
105 temp_path.display()
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 atomic_write(&path, &new_content)?;
147
148 let diff = generate_diff(&content, &new_content, old_string, new_string);
150 Ok(diff)
151}
152
153fn generate_diff(
155 old_content: &str,
156 new_content: &str,
157 old_string: &str,
158 new_string: &str,
159) -> String {
160 let old_lines: Vec<&str> = old_content.lines().collect();
161 let new_lines: Vec<&str> = new_content.lines().collect();
162
163 let removed_count = old_string.lines().count();
164 let added_count = new_string.lines().count();
165
166 let prefix_len = old_content[..old_content.find(old_string).unwrap_or(0)].len();
168 let change_start_line = old_content[..prefix_len].matches('\n').count();
169
170 let context_lines = 3;
171 let diff_start = change_start_line.saturating_sub(context_lines);
172 let new_diff_end = (change_start_line + added_count + context_lines).min(new_lines.len());
173
174 let mut output = String::new();
175 output.push_str(&format!(
176 "Added {} lines, removed {} lines\n",
177 added_count, removed_count
178 ));
179
180 for i in diff_start..change_start_line {
182 if i < old_lines.len() {
183 output.push_str(&format!("{:>4} {}\n", i + 1, old_lines[i]));
184 }
185 }
186
187 for i in 0..removed_count {
189 let line_num = change_start_line + i;
190 if line_num < old_lines.len() {
191 output.push_str(&format!("{:>4} - {}\n", line_num + 1, old_lines[line_num]));
192 }
193 }
194
195 for i in 0..added_count {
197 let line_num = change_start_line + i;
198 if line_num < new_lines.len() {
199 output.push_str(&format!("{:>4} + {}\n", line_num + 1, new_lines[line_num]));
200 }
201 }
202
203 let context_after_start = change_start_line + added_count;
205 for i in context_after_start..new_diff_end {
206 if i < new_lines.len() {
207 output.push_str(&format!("{:>4} {}\n", i + 1, new_lines[i]));
208 }
209 }
210
211 output
212}
213
214pub fn delete_file(path: &str) -> Result<()> {
216 let path = normalize_path(path)?;
217
218 validate_path(&path)?;
220
221 if path.exists() {
223 create_timestamped_backup(&path)?;
224 }
225
226 fs::remove_file(&path).with_context(|| format!("Failed to delete file: {}", path.display()))
227}
228
229pub fn create_directory(path: &str) -> Result<()> {
231 let path = normalize_path(path)?;
232
233 validate_path(&path)?;
235
236 fs::create_dir_all(&path)
237 .with_context(|| format!("Failed to create directory: {}", path.display()))
238}
239
240fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
242 let path = Path::new(path);
243
244 if path.is_absolute() {
245 Ok(path.to_path_buf())
247 } else {
248 let current_dir = std::env::current_dir()?;
250 Ok(current_dir.join(path))
251 }
252}
253
254fn normalize_path(path: &str) -> Result<PathBuf> {
256 let path = Path::new(path);
257
258 for component in path.components() {
262 if matches!(component, std::path::Component::ParentDir) {
263 anyhow::bail!("Access denied: path contains '..' component");
264 }
265 }
266
267 if path.is_absolute() {
268 let current_dir = std::env::current_dir()?;
270 if !path.starts_with(¤t_dir) {
271 anyhow::bail!("Access denied: path outside of project directory");
272 }
273 Ok(path.to_path_buf())
274 } else {
275 let current_dir = std::env::current_dir()?;
277 Ok(current_dir.join(path))
278 }
279}
280
281fn is_sensitive_path(path: &Path) -> bool {
287 let sensitive_dirs = [".ssh", ".aws", ".gnupg", ".docker"];
289
290 let sensitive_filenames = [
292 ".npmrc",
293 ".pypirc",
294 ".netrc",
295 "id_rsa",
296 "id_ed25519",
297 "id_ecdsa",
298 "id_dsa",
299 "credentials.json",
300 "secrets.yaml",
301 "secrets.yml",
302 "token.json",
303 "config.json", ];
305
306 let sensitive_extensions = ["pem", "key"];
308
309 let path_str = path.to_string_lossy();
310
311 if path_str.contains(".git/config") || path_str.contains(".git\\config") {
313 return true;
314 }
315
316 if (path_str.contains("mermaid/config.toml") || path_str.contains("mermaid\\config.toml"))
318 && (path_str.contains(".config/") || path_str.contains(".config\\"))
319 {
320 return true;
321 }
322
323 for component in path.components() {
324 let name = component.as_os_str().to_string_lossy();
325
326 for dir in &sensitive_dirs {
328 if name == *dir {
329 return true;
330 }
331 }
332
333 if name == ".env" || name.starts_with(".env.") {
336 return true;
337 }
338
339 for filename in &sensitive_filenames {
341 if name == *filename {
342 return true;
343 }
344 }
345 }
346
347 if let Some(ext) = path.extension() {
349 let ext_str = ext.to_string_lossy().to_lowercase();
350 for sensitive_ext in &sensitive_extensions {
351 if ext_str == *sensitive_ext {
352 return true;
353 }
354 }
355 }
356
357 false
358}
359
360fn validate_path_for_read(path: &Path) -> Result<()> {
362 if is_sensitive_path(path) {
363 anyhow::bail!(
364 "Security error: attempted to access potentially sensitive file: {}",
365 path.display()
366 );
367 }
368 Ok(())
369}
370
371fn validate_path(path: &Path) -> Result<()> {
373 let current_dir = std::env::current_dir()?;
374
375 let canonical = if path.exists() {
378 path.canonicalize()?
379 } else {
380 let mut ancestors_to_join = Vec::new();
382 let mut current = path;
383
384 while let Some(parent) = current.parent() {
385 if let Some(name) = current.file_name() {
386 ancestors_to_join.push(name.to_os_string());
387 }
388 if parent.as_os_str().is_empty() {
389 break;
391 }
392 if parent.exists() {
393 let mut result = parent.canonicalize()?;
395 for component in ancestors_to_join.iter().rev() {
396 result = result.join(component);
397 }
398 return validate_canonical_path(&result, ¤t_dir);
399 }
400 current = parent;
401 }
402
403 let mut result = current_dir
405 .canonicalize()
406 .unwrap_or_else(|_| current_dir.clone());
407 for component in ancestors_to_join.iter().rev() {
408 result = result.join(component);
409 }
410 result
411 };
412
413 validate_canonical_path(&canonical, ¤t_dir)
414}
415
416fn validate_canonical_path(canonical: &Path, current_dir: &Path) -> Result<()> {
418 let current_dir_canonical = current_dir
420 .canonicalize()
421 .unwrap_or_else(|_| current_dir.to_path_buf());
422
423 if !canonical.starts_with(¤t_dir_canonical) {
425 anyhow::bail!(
426 "Security error: attempted to access path outside of project directory: {}",
427 canonical.display()
428 );
429 }
430
431 if is_sensitive_path(canonical) {
433 anyhow::bail!(
434 "Security error: attempted to access potentially sensitive file: {}",
435 canonical.display()
436 );
437 }
438
439 Ok(())
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
449 fn test_read_file_valid() {
450 let result = read_file("Cargo.toml");
452 assert!(
453 result.is_ok(),
454 "Should successfully read valid file from project"
455 );
456 let content = result.unwrap();
457 assert!(
458 content.contains("[package]") || !content.is_empty(),
459 "Content should be reasonable"
460 );
461 }
462
463 #[test]
464 fn test_read_file_not_found() {
465 let result = read_file("this_file_definitely_does_not_exist_12345.txt");
466 assert!(result.is_err(), "Should fail to read non-existent file");
467 let err_msg = result.unwrap_err().to_string();
468 assert!(
469 err_msg.contains("Failed to read file"),
470 "Error message should indicate read failure, got: {}",
471 err_msg
472 );
473 }
474
475 #[test]
476 fn test_write_and_read_roundtrip() {
477 let test_path = "target/test_write_roundtrip.txt";
479 let content = "Hello, Mermaid!";
480 let result = write_file(test_path, content);
481 assert!(result.is_ok(), "Write should succeed in target/");
482
483 let read_back = read_file(test_path);
484 assert!(read_back.is_ok(), "Should read back written file");
485 assert_eq!(read_back.unwrap(), content);
486
487 let _ = fs::remove_file(test_path);
489 let _ = fs::remove_file(format!("{}.backup", test_path));
491 }
492
493 #[test]
494 fn test_delete_file_not_found() {
495 let result = delete_file("this_definitely_should_not_exist_xyz123.txt");
496 assert!(result.is_err(), "Should fail to delete non-existent file");
497 }
498
499 #[test]
500 fn test_create_directory_simple() {
501 let dir_path = "target/test_dir_creation";
502
503 let result = create_directory(dir_path);
504 assert!(result.is_ok(), "Should successfully create directory");
505
506 let full_path = Path::new(dir_path);
507 assert!(full_path.exists(), "Directory should exist");
508 assert!(full_path.is_dir(), "Should be a directory");
509
510 fs::remove_dir(dir_path).ok();
512 }
513
514 #[test]
515 fn test_create_nested_directories_all() {
516 let nested_path = "target/level1/level2/level3";
517
518 let result = create_directory(nested_path);
519 assert!(
520 result.is_ok(),
521 "Should create nested directories: {}",
522 result.unwrap_err()
523 );
524
525 let full_path = Path::new(nested_path);
526 assert!(full_path.exists(), "Nested directory should exist");
527 assert!(full_path.is_dir(), "Should be a directory");
528
529 fs::remove_dir_all("target/level1").ok();
531 }
532
533 #[test]
534 fn test_path_validation_blocks_dotenv() {
535 let result = read_file(".env");
536 assert!(result.is_err(), "Should reject .env file access");
537 let error = result.unwrap_err().to_string();
538 assert!(
539 error.contains("Security"),
540 "Error should mention Security: {}",
541 error
542 );
543 }
544
545 #[test]
546 fn test_path_validation_blocks_dotenv_variants() {
547 assert!(is_sensitive_path(Path::new("/project/.env.local")));
549 assert!(is_sensitive_path(Path::new("/project/.env.production")));
550 assert!(!is_sensitive_path(Path::new(
552 "/project/src/.environment.ts"
553 )));
554 assert!(!is_sensitive_path(Path::new("/project/src/environment.rs")));
555 }
556
557 #[test]
558 fn test_path_validation_blocks_ssh_keys() {
559 let result = read_file(".ssh/id_rsa");
560 assert!(result.is_err(), "Should reject .ssh/id_rsa access");
561 let error = result.unwrap_err().to_string();
562 assert!(
563 error.contains("Security"),
564 "Error should mention Security: {}",
565 error
566 );
567 }
568
569 #[test]
570 fn test_path_validation_blocks_aws_credentials() {
571 let result = read_file(".aws/credentials");
572 assert!(result.is_err(), "Should reject .aws/credentials access");
573 let error = result.unwrap_err().to_string();
574 assert!(
575 error.contains("Security"),
576 "Error should mention Security: {}",
577 error
578 );
579 }
580
581 #[test]
582 fn test_path_validation_blocks_new_sensitive_patterns() {
583 assert!(is_sensitive_path(Path::new("/home/user/credentials.json")));
585 assert!(is_sensitive_path(Path::new("/project/secrets.yaml")));
586 assert!(is_sensitive_path(Path::new("/project/server.pem")));
587 assert!(is_sensitive_path(Path::new("/project/private.key")));
588 assert!(is_sensitive_path(Path::new("/project/token.json")));
589 assert!(is_sensitive_path(Path::new(
590 "/home/user/.gnupg/pubring.kbx"
591 )));
592 assert!(is_sensitive_path(Path::new(
594 "/home/user/.docker/config.json"
595 )));
596 assert!(is_sensitive_path(Path::new("/home/user/.netrc")));
597 assert!(is_sensitive_path(Path::new(
599 "/home/user/.config/mermaid/config.toml"
600 )));
601 assert!(!is_sensitive_path(Path::new("/project/config.toml")));
603 }
604}