1use anyhow::{Context, Result};
2use base64::{Engine as _, engine::general_purpose};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6pub const DIFF_REMOVED_MARKER: &str = " - ";
12
13pub const DIFF_ADDED_MARKER: &str = " + ";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DiffLineKind {
23 Context,
24 Removed,
25 Added,
26}
27
28pub fn parse_diff_line(line: &str) -> DiffLineKind {
38 let trimmed = line.trim_start();
39 let after_num = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
40 if after_num.starts_with(DIFF_REMOVED_MARKER) {
41 DiffLineKind::Removed
42 } else if after_num.starts_with(DIFF_ADDED_MARKER) {
43 DiffLineKind::Added
44 } else {
45 DiffLineKind::Context
46 }
47}
48
49pub fn read_file(path: &str) -> Result<String> {
51 let path = normalize_path_for_read(path)?;
52
53 validate_path_for_read(&path)?;
55
56 fs::read_to_string(&path).with_context(|| format!("Failed to read file: {}", path.display()))
57}
58
59pub async fn read_file_async(path: String) -> Result<String> {
61 tokio::task::spawn_blocking(move || read_file(&path))
62 .await
63 .context("Failed to spawn blocking task for file read")?
64}
65
66pub fn is_binary_file(path: &str) -> bool {
68 let path = Path::new(path);
69 if let Some(ext) = path.extension() {
70 let ext_str = ext.to_string_lossy().to_lowercase();
71 matches!(
72 ext_str.as_str(),
73 "pdf" | "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff"
74 )
75 } else {
76 false
77 }
78}
79
80pub fn read_binary_file(path: &str) -> Result<String> {
82 let path = normalize_path_for_read(path)?;
83
84 validate_path_for_read(&path)?;
86
87 let bytes = fs::read(&path)
88 .with_context(|| format!("Failed to read binary file: {}", path.display()))?;
89
90 Ok(general_purpose::STANDARD.encode(&bytes))
91}
92
93pub fn write_file(path: &str, content: &str) -> Result<()> {
95 let path = normalize_path(path)?;
96
97 validate_path(&path)?;
99
100 if let Some(parent) = path.parent() {
102 fs::create_dir_all(parent).with_context(|| {
103 format!(
104 "Failed to create parent directories for: {}",
105 path.display()
106 )
107 })?;
108 }
109
110 if path.exists() {
112 create_timestamped_backup(&path)?;
113 }
114
115 atomic_write(&path, content)
116}
117
118fn create_timestamped_backup(path: &std::path::Path) -> Result<()> {
121 let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S");
122 let backup_path = format!("{}.backup.{}", path.display(), timestamp);
123
124 fs::copy(path, &backup_path).with_context(|| {
125 format!(
126 "Failed to create backup of: {} to {}",
127 path.display(),
128 backup_path
129 )
130 })?;
131
132 Ok(())
133}
134
135fn atomic_write(path: &Path, content: &str) -> Result<()> {
138 let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
139 let temp_path = PathBuf::from(&temp_path);
140
141 fs::write(&temp_path, content)
142 .with_context(|| format!("Failed to write to temporary file: {}", temp_path.display()))?;
143
144 fs::rename(&temp_path, path).with_context(|| {
145 format!(
146 "Failed to finalize write to: {} (temp file: {})",
147 path.display(),
148 temp_path.display()
149 )
150 })?;
151
152 Ok(())
153}
154
155pub fn edit_file(path: &str, old_string: &str, new_string: &str) -> Result<String> {
158 let path = normalize_path(path)?;
159
160 validate_path(&path)?;
162
163 let content = fs::read_to_string(&path)
165 .with_context(|| format!("Failed to read file for editing: {}", path.display()))?;
166
167 let match_count = content.matches(old_string).count();
169 if match_count == 0 {
170 anyhow::bail!(
171 "old_string not found in {}. Make sure the text matches exactly, including whitespace and indentation.",
172 path.display()
173 );
174 }
175 if match_count > 1 {
176 anyhow::bail!(
177 "old_string appears {} times in {}. It must be unique. Include more surrounding context to make it unique.",
178 match_count,
179 path.display()
180 );
181 }
182
183 let new_content = content.replacen(old_string, new_string, 1);
185
186 create_timestamped_backup(&path)?;
188
189 atomic_write(&path, &new_content)?;
190
191 let diff = generate_diff(&content, &new_content, old_string, new_string);
193 Ok(diff)
194}
195
196fn generate_diff(
198 old_content: &str,
199 new_content: &str,
200 old_string: &str,
201 new_string: &str,
202) -> String {
203 let old_lines: Vec<&str> = old_content.lines().collect();
204 let new_lines: Vec<&str> = new_content.lines().collect();
205
206 let removed_count = old_string.lines().count();
207 let added_count = new_string.lines().count();
208
209 let change_start = old_content.find(old_string).unwrap_or(0);
211 let change_start_line = old_content[..change_start].matches('\n').count();
212
213 let context_lines = 3;
214 let diff_start = change_start_line.saturating_sub(context_lines);
215 let new_diff_end = (change_start_line + added_count + context_lines).min(new_lines.len());
216
217 let mut output = String::new();
218 output.push_str(&format!(
219 "Added {} lines, removed {} lines\n",
220 added_count, removed_count
221 ));
222
223 for i in diff_start..change_start_line {
225 if i < old_lines.len() {
226 output.push_str(&format!("{:>4} {}\n", i + 1, old_lines[i]));
227 }
228 }
229
230 for i in 0..removed_count {
232 let line_num = change_start_line + i;
233 if line_num < old_lines.len() {
234 output.push_str(&format!(
235 "{:>4}{}{}\n",
236 line_num + 1,
237 DIFF_REMOVED_MARKER,
238 old_lines[line_num]
239 ));
240 }
241 }
242
243 for i in 0..added_count {
245 let line_num = change_start_line + i;
246 if line_num < new_lines.len() {
247 output.push_str(&format!(
248 "{:>4}{}{}\n",
249 line_num + 1,
250 DIFF_ADDED_MARKER,
251 new_lines[line_num]
252 ));
253 }
254 }
255
256 let context_after_start = change_start_line + added_count;
258 for i in context_after_start..new_diff_end {
259 if i < new_lines.len() {
260 output.push_str(&format!("{:>4} {}\n", i + 1, new_lines[i]));
261 }
262 }
263
264 output
265}
266
267pub fn delete_file(path: &str) -> Result<()> {
269 let path = normalize_path(path)?;
270
271 validate_path(&path)?;
273
274 if path.exists() {
276 create_timestamped_backup(&path)?;
277 }
278
279 fs::remove_file(&path).with_context(|| format!("Failed to delete file: {}", path.display()))
280}
281
282pub fn create_directory(path: &str) -> Result<()> {
284 let path = normalize_path(path)?;
285
286 validate_path(&path)?;
288
289 fs::create_dir_all(&path)
290 .with_context(|| format!("Failed to create directory: {}", path.display()))
291}
292
293fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
295 let path = Path::new(path);
296
297 if path.is_absolute() {
298 Ok(path.to_path_buf())
300 } else {
301 let current_dir = std::env::current_dir()?;
303 Ok(current_dir.join(path))
304 }
305}
306
307fn normalize_path(path: &str) -> Result<PathBuf> {
309 let path = Path::new(path);
310
311 for component in path.components() {
315 if matches!(component, std::path::Component::ParentDir) {
316 anyhow::bail!("Access denied: path contains '..' component");
317 }
318 }
319
320 if path.is_absolute() {
321 let current_dir = std::env::current_dir()?;
323 if !path.starts_with(¤t_dir) {
324 anyhow::bail!("Access denied: path outside of project directory");
325 }
326 Ok(path.to_path_buf())
327 } else {
328 let current_dir = std::env::current_dir()?;
330 Ok(current_dir.join(path))
331 }
332}
333
334fn is_sensitive_path(path: &Path) -> bool {
340 let sensitive_dirs = [".ssh", ".aws", ".gnupg", ".docker"];
342
343 let sensitive_filenames = [
352 ".npmrc",
353 ".pypirc",
354 ".netrc",
355 "id_rsa",
356 "id_ed25519",
357 "id_ecdsa",
358 "id_dsa",
359 "credentials.json",
360 "secrets.yaml",
361 "secrets.yml",
362 "token.json",
363 ];
364
365 let sensitive_extensions = ["pem", "key"];
367
368 let path_str = path.to_string_lossy();
369
370 if (path_str.contains("mermaid/config.toml") || path_str.contains("mermaid\\config.toml"))
372 && (path_str.contains(".config/") || path_str.contains(".config\\"))
373 {
374 return true;
375 }
376
377 let mut prev_was_dot_git = false;
381 for component in path.components() {
382 let name = component.as_os_str().to_string_lossy();
383
384 if prev_was_dot_git && name == "config" {
386 return true;
387 }
388 prev_was_dot_git = name == ".git";
389
390 for dir in &sensitive_dirs {
392 if name == *dir {
393 return true;
394 }
395 }
396
397 if name == ".env" || name.starts_with(".env.") {
400 return true;
401 }
402
403 for filename in &sensitive_filenames {
405 if name == *filename {
406 return true;
407 }
408 }
409 }
410
411 if let Some(ext) = path.extension() {
413 let ext_str = ext.to_string_lossy().to_lowercase();
414 for sensitive_ext in &sensitive_extensions {
415 if ext_str == *sensitive_ext {
416 return true;
417 }
418 }
419 }
420
421 false
422}
423
424fn validate_path_for_read(path: &Path) -> Result<()> {
426 if is_sensitive_path(path) {
427 anyhow::bail!(
428 "Security error: attempted to access potentially sensitive file: {}",
429 path.display()
430 );
431 }
432 Ok(())
433}
434
435fn validate_path(path: &Path) -> Result<()> {
437 let current_dir = std::env::current_dir()?;
438
439 let canonical = if path.exists() {
442 path.canonicalize()?
443 } else {
444 let mut ancestors_to_join = Vec::new();
446 let mut current = path;
447
448 while let Some(parent) = current.parent() {
449 if let Some(name) = current.file_name() {
450 ancestors_to_join.push(name.to_os_string());
451 }
452 if parent.as_os_str().is_empty() {
453 break;
455 }
456 if parent.exists() {
457 let mut result = parent.canonicalize()?;
459 for component in ancestors_to_join.iter().rev() {
460 result = result.join(component);
461 }
462 return validate_canonical_path(&result, ¤t_dir);
463 }
464 current = parent;
465 }
466
467 let mut result = current_dir
469 .canonicalize()
470 .unwrap_or_else(|_| current_dir.clone());
471 for component in ancestors_to_join.iter().rev() {
472 result = result.join(component);
473 }
474 result
475 };
476
477 validate_canonical_path(&canonical, ¤t_dir)
478}
479
480fn validate_canonical_path(canonical: &Path, current_dir: &Path) -> Result<()> {
482 let current_dir_canonical = current_dir
484 .canonicalize()
485 .unwrap_or_else(|_| current_dir.to_path_buf());
486
487 if !canonical.starts_with(¤t_dir_canonical) {
489 anyhow::bail!(
490 "Security error: attempted to access path outside of project directory: {}",
491 canonical.display()
492 );
493 }
494
495 if is_sensitive_path(canonical) {
497 anyhow::bail!(
498 "Security error: attempted to access potentially sensitive file: {}",
499 canonical.display()
500 );
501 }
502
503 Ok(())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
513 fn parse_diff_line_classifies_each_marker() {
514 assert_eq!(
518 parse_diff_line(&format!("{:>4}{}two", 2, DIFF_REMOVED_MARKER)),
519 DiffLineKind::Removed,
520 );
521 assert_eq!(
522 parse_diff_line(&format!("{:>4}{}two-updated", 2, DIFF_ADDED_MARKER)),
523 DiffLineKind::Added,
524 );
525 assert_eq!(parse_diff_line(" 1 one"), DiffLineKind::Context);
527 }
528
529 #[test]
530 fn parse_diff_line_treats_malformed_as_context() {
531 assert_eq!(parse_diff_line(""), DiffLineKind::Context);
534 assert_eq!(parse_diff_line("garbage"), DiffLineKind::Context);
535 assert_eq!(parse_diff_line(" - no digit prefix"), DiffLineKind::Context);
536 }
537
538 #[test]
539 fn generate_diff_lines_round_trip_through_parse_diff_line() {
540 let old = "one\ntwo\nthree\n";
545 let new = "one\ntwo-updated\nthree\n";
546 let diff = generate_diff(old, new, "two", "two-updated");
547
548 let mut saw_removed = false;
549 let mut saw_added = false;
550 for line in diff.lines() {
551 match parse_diff_line(line) {
552 DiffLineKind::Removed => saw_removed = true,
553 DiffLineKind::Added => saw_added = true,
554 DiffLineKind::Context => {},
555 }
556 }
557 assert!(saw_removed, "diff should classify the old line as Removed");
558 assert!(saw_added, "diff should classify the new line as Added");
559 }
560
561 #[test]
569 fn generate_diff_uses_shared_markers() {
570 let old = "one\ntwo\nthree\n";
571 let new = "one\ntwo-updated\nthree\n";
572 let diff = generate_diff(old, new, "two", "two-updated");
573
574 assert!(
575 diff.contains(DIFF_REMOVED_MARKER),
576 "diff output should contain DIFF_REMOVED_MARKER ({:?}); got:\n{}",
577 DIFF_REMOVED_MARKER,
578 diff
579 );
580 assert!(
581 diff.contains(DIFF_ADDED_MARKER),
582 "diff output should contain DIFF_ADDED_MARKER ({:?}); got:\n{}",
583 DIFF_ADDED_MARKER,
584 diff
585 );
586 }
587
588 #[test]
591 fn test_read_file_valid() {
592 let result = read_file("Cargo.toml");
594 assert!(
595 result.is_ok(),
596 "Should successfully read valid file from project"
597 );
598 let content = result.unwrap();
599 assert!(
600 content.contains("[package]") || !content.is_empty(),
601 "Content should be reasonable"
602 );
603 }
604
605 #[test]
606 fn test_read_file_not_found() {
607 let result = read_file("this_file_definitely_does_not_exist_12345.txt");
608 assert!(result.is_err(), "Should fail to read non-existent file");
609 let err_msg = result.unwrap_err().to_string();
610 assert!(
611 err_msg.contains("Failed to read file"),
612 "Error message should indicate read failure, got: {}",
613 err_msg
614 );
615 }
616
617 #[test]
618 fn test_write_and_read_roundtrip() {
619 let test_path = "target/test_write_roundtrip.txt";
621 let content = "Hello, Mermaid!";
622 let result = write_file(test_path, content);
623 assert!(result.is_ok(), "Write should succeed in target/");
624
625 let read_back = read_file(test_path);
626 assert!(read_back.is_ok(), "Should read back written file");
627 assert_eq!(read_back.unwrap(), content);
628
629 let _ = fs::remove_file(test_path);
631 let _ = fs::remove_file(format!("{}.backup", test_path));
633 }
634
635 #[test]
636 fn test_delete_file_not_found() {
637 let result = delete_file("this_definitely_should_not_exist_xyz123.txt");
638 assert!(result.is_err(), "Should fail to delete non-existent file");
639 }
640
641 #[test]
642 fn test_create_directory_simple() {
643 let dir_path = "target/test_dir_creation";
644
645 let result = create_directory(dir_path);
646 assert!(result.is_ok(), "Should successfully create directory");
647
648 let full_path = Path::new(dir_path);
649 assert!(full_path.exists(), "Directory should exist");
650 assert!(full_path.is_dir(), "Should be a directory");
651
652 fs::remove_dir(dir_path).ok();
654 }
655
656 #[test]
657 fn test_create_nested_directories_all() {
658 let nested_path = "target/level1/level2/level3";
659
660 let result = create_directory(nested_path);
661 assert!(
662 result.is_ok(),
663 "Should create nested directories: {}",
664 result.unwrap_err()
665 );
666
667 let full_path = Path::new(nested_path);
668 assert!(full_path.exists(), "Nested directory should exist");
669 assert!(full_path.is_dir(), "Should be a directory");
670
671 fs::remove_dir_all("target/level1").ok();
673 }
674
675 #[test]
676 fn test_path_validation_blocks_dotenv() {
677 let result = read_file(".env");
678 assert!(result.is_err(), "Should reject .env file access");
679 let error = result.unwrap_err().to_string();
680 assert!(
681 error.contains("Security"),
682 "Error should mention Security: {}",
683 error
684 );
685 }
686
687 #[test]
688 fn test_path_validation_blocks_dotenv_variants() {
689 assert!(is_sensitive_path(Path::new("/project/.env.local")));
691 assert!(is_sensitive_path(Path::new("/project/.env.production")));
692 assert!(!is_sensitive_path(Path::new(
694 "/project/src/.environment.ts"
695 )));
696 assert!(!is_sensitive_path(Path::new("/project/src/environment.rs")));
697 }
698
699 #[test]
700 fn test_path_validation_blocks_ssh_keys() {
701 let result = read_file(".ssh/id_rsa");
702 assert!(result.is_err(), "Should reject .ssh/id_rsa access");
703 let error = result.unwrap_err().to_string();
704 assert!(
705 error.contains("Security"),
706 "Error should mention Security: {}",
707 error
708 );
709 }
710
711 #[test]
712 fn test_path_validation_blocks_aws_credentials() {
713 let result = read_file(".aws/credentials");
714 assert!(result.is_err(), "Should reject .aws/credentials access");
715 let error = result.unwrap_err().to_string();
716 assert!(
717 error.contains("Security"),
718 "Error should mention Security: {}",
719 error
720 );
721 }
722
723 #[test]
724 fn test_git_config_path_component_matching() {
725 assert!(is_sensitive_path(Path::new("/repo/.git/config")));
727 assert!(is_sensitive_path(Path::new(".git/config")));
728
729 assert!(is_sensitive_path(Path::new("/some/path/.git/config")));
731
732 assert!(!is_sensitive_path(Path::new("/repo/.git/config-template")));
735 assert!(!is_sensitive_path(Path::new("/repo/.git/config.local")));
736 assert!(!is_sensitive_path(Path::new(
737 "/repo/.git/configuration.json"
738 )));
739
740 assert!(!is_sensitive_path(Path::new("/repo/.git/HEAD")));
742 assert!(!is_sensitive_path(Path::new("/repo/.git/hooks/pre-commit")));
743
744 assert!(!is_sensitive_path(Path::new("/repo/notgit/config")));
746 assert!(!is_sensitive_path(Path::new("/etc/git-style/config")));
747 }
748
749 #[test]
750 fn test_path_validation_blocks_new_sensitive_patterns() {
751 assert!(is_sensitive_path(Path::new("/home/user/credentials.json")));
753 assert!(is_sensitive_path(Path::new("/project/secrets.yaml")));
754 assert!(is_sensitive_path(Path::new("/project/server.pem")));
755 assert!(is_sensitive_path(Path::new("/project/private.key")));
756 assert!(is_sensitive_path(Path::new("/project/token.json")));
757 assert!(is_sensitive_path(Path::new(
758 "/home/user/.gnupg/pubring.kbx"
759 )));
760 assert!(is_sensitive_path(Path::new(
763 "/home/user/.docker/config.json"
764 )));
765 assert!(is_sensitive_path(Path::new("/home/user/.netrc")));
766 assert!(is_sensitive_path(Path::new(
768 "/home/user/.config/mermaid/config.toml"
769 )));
770 assert!(!is_sensitive_path(Path::new("/project/config.toml")));
772 assert!(!is_sensitive_path(Path::new("/project/config.json")));
775 assert!(!is_sensitive_path(Path::new("/project/src/config.json")));
776 }
777}