Skip to main content

mermaid_cli/agents/
filesystem.rs

1use anyhow::{Context, Result};
2use base64::{Engine as _, engine::general_purpose};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Marker string used in `generate_diff` output to denote a REMOVED line.
7///
8/// The TUI renderer in `src/tui/widgets/chat.rs` consumes this through
9/// `parse_diff_line` (below), which keeps producer and consumer in
10/// lockstep — no inline regex / substring heuristic in the renderer.
11pub const DIFF_REMOVED_MARKER: &str = " - ";
12
13/// Marker string used in `generate_diff` output to denote an ADDED line.
14/// See `DIFF_REMOVED_MARKER` for the renderer-coupling note.
15pub const DIFF_ADDED_MARKER: &str = " + ";
16
17/// Classification of a line emitted by `generate_diff`. The TUI renderer
18/// uses this to decide which background color (red / green / none) to
19/// apply, instead of re-deriving the kind from substring + digit-prefix
20/// heuristics that drift when the format changes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DiffLineKind {
23    Context,
24    Removed,
25    Added,
26}
27
28/// Parse a single line from `generate_diff` output. The format is
29/// `format!("{:>4}{marker}{content}", line_num, marker, content)` where
30/// `marker` is one of `"   "` (context), `" - "` (removed), `" + "` (added)
31/// — all 3 bytes wide. Lives next to the markers above so the producer and
32/// the parser cannot drift independently.
33///
34/// Lines that don't follow the expected shape (no leading digit, no marker
35/// after the digits, etc.) fall through to `Context`. That keeps the
36/// renderer's match exhaustive without panicking on malformed input.
37pub 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
49/// Read a file from the filesystem
50pub fn read_file(path: &str) -> Result<String> {
51    let path = normalize_path_for_read(path)?;
52
53    // Security check: block sensitive files but allow reading outside project
54    validate_path_for_read(&path)?;
55
56    fs::read_to_string(&path).with_context(|| format!("Failed to read file: {}", path.display()))
57}
58
59/// Read a file from the filesystem asynchronously (for parallel operations)
60pub 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
66/// Check if a file is a binary format that should be base64-encoded
67pub 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
80/// Read a binary file and encode it as base64
81pub fn read_binary_file(path: &str) -> Result<String> {
82    let path = normalize_path_for_read(path)?;
83
84    // Security check: block sensitive files but allow reading outside project
85    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
93/// Write content to a file atomically with timestamped backup
94pub fn write_file(path: &str, content: &str) -> Result<()> {
95    let path = normalize_path(path)?;
96
97    // Security check
98    validate_path(&path)?;
99
100    // Create parent directories if they don't exist
101    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    // Create timestamped backup if file exists
111    if path.exists() {
112        create_timestamped_backup(&path)?;
113    }
114
115    atomic_write(&path, content)
116}
117
118/// Create a timestamped backup of a file
119/// Format: file.txt.backup.2025-10-20-01-45-32
120fn 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
135/// Atomically write content to a file by writing to a temporary file first,
136/// then renaming. This prevents partial writes if the process is interrupted.
137fn 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
155/// Edit a file by replacing a unique occurrence of old_string with new_string
156/// Returns a unified diff showing the changes
157pub fn edit_file(path: &str, old_string: &str, new_string: &str) -> Result<String> {
158    let path = normalize_path(path)?;
159
160    // Security check
161    validate_path(&path)?;
162
163    // Read current content
164    let content = fs::read_to_string(&path)
165        .with_context(|| format!("Failed to read file for editing: {}", path.display()))?;
166
167    // Check that old_string occurs exactly once
168    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    // Perform the replacement
184    let new_content = content.replacen(old_string, new_string, 1);
185
186    // Create timestamped backup
187    create_timestamped_backup(&path)?;
188
189    atomic_write(&path, &new_content)?;
190
191    // Generate diff
192    let diff = generate_diff(&content, &new_content, old_string, new_string);
193    Ok(diff)
194}
195
196/// Generate a unified diff showing the changed lines with context
197fn 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    // Find where the change starts in the old content
210    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    // Context before
224    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    // Removed lines
231    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    // Added lines
244    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    // Context after
257    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
267/// Delete a file with timestamped backup (for recovery)
268pub fn delete_file(path: &str) -> Result<()> {
269    let path = normalize_path(path)?;
270
271    // Security check
272    validate_path(&path)?;
273
274    // Create timestamped backup before deletion
275    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
282/// Create a directory
283pub fn create_directory(path: &str) -> Result<()> {
284    let path = normalize_path(path)?;
285
286    // Security check
287    validate_path(&path)?;
288
289    fs::create_dir_all(&path)
290        .with_context(|| format!("Failed to create directory: {}", path.display()))
291}
292
293/// Normalize a path for reading (allows absolute paths anywhere)
294fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
295    let path = Path::new(path);
296
297    if path.is_absolute() {
298        // For absolute paths, return as-is (user has specified exact location)
299        Ok(path.to_path_buf())
300    } else {
301        // For relative paths, resolve from current directory
302        let current_dir = std::env::current_dir()?;
303        Ok(current_dir.join(path))
304    }
305}
306
307/// Normalize a path (resolve relative paths) - strict version for writes
308fn normalize_path(path: &str) -> Result<PathBuf> {
309    let path = Path::new(path);
310
311    // Reject paths containing ".." to prevent directory traversal.
312    // Symlinks in existing ancestors are resolved by canonicalize() in validate_path,
313    // but ".." in non-existent portions would be silently dropped by file_name().
314    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        // For absolute paths, ensure they're within the current directory
322        let current_dir = std::env::current_dir()?;
323        if !path.starts_with(&current_dir) {
324            anyhow::bail!("Access denied: path outside of project directory");
325        }
326        Ok(path.to_path_buf())
327    } else {
328        // For relative paths, resolve from current directory
329        let current_dir = std::env::current_dir()?;
330        Ok(current_dir.join(path))
331    }
332}
333
334/// Check if a path component or filename matches a sensitive pattern.
335///
336/// Uses path-component matching (not substring) to avoid false positives
337/// like ".environment.ts" matching ".env". Checks both directory components
338/// and file extensions.
339fn is_sensitive_path(path: &Path) -> bool {
340    // Directory components that are always sensitive
341    let sensitive_dirs = [".ssh", ".aws", ".gnupg", ".docker"];
342
343    // Filenames/extensions that are sensitive.
344    //
345    // Note: "config.json" is intentionally NOT listed here. That name is
346    // far too common (frontend tooling, editor configs, language servers)
347    // and a bare match would false-positive on legit project files. The
348    // Docker-credential case (~/.docker/config.json) is already covered
349    // by the ".docker" sensitive-directory rule below, which matches any
350    // file inside a .docker directory.
351    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    // File extensions that are sensitive
366    let sensitive_extensions = ["pem", "key"];
367
368    let path_str = path.to_string_lossy();
369
370    // Check for mermaid config (contains cloud_api_key)
371    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    // Walk components for path-segment matches. Tracks `prev_was_dot_git` so
378    // we can detect the `.git/config` pair as adjacent components and avoid
379    // false-positives like `.git/config-template` or `.git/config.local`.
380    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        // .git/config (file) — exact-equality on the component name
385        if prev_was_dot_git && name == "config" {
386            return true;
387        }
388        prev_was_dot_git = name == ".git";
389
390        // Check sensitive directories
391        for dir in &sensitive_dirs {
392            if name == *dir {
393                return true;
394            }
395        }
396
397        // Check .env files: match ".env" exactly or ".env.*" (like .env.local, .env.production)
398        // but NOT files that merely contain "env" (like .environment.ts)
399        if name == ".env" || name.starts_with(".env.") {
400            return true;
401        }
402
403        // Check sensitive filenames
404        for filename in &sensitive_filenames {
405            if name == *filename {
406                return true;
407            }
408        }
409    }
410
411    // Check sensitive extensions
412    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
424/// Validate that a path is safe to read from (blocks sensitive files only)
425fn 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
435/// Validate that a path is safe to write to (strict - must be in project)
436fn validate_path(path: &Path) -> Result<()> {
437    let current_dir = std::env::current_dir()?;
438
439    // Resolve the path to handle .. and .
440    // For non-existent paths, walk up to find the first existing ancestor
441    let canonical = if path.exists() {
442        path.canonicalize()?
443    } else {
444        // Walk up the path to find the first existing ancestor
445        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                // Reached the root of a relative path
454                break;
455            }
456            if parent.exists() {
457                // Found existing ancestor - canonicalize it and join the rest
458                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, &current_dir);
463            }
464            current = parent;
465        }
466
467        // No existing ancestor found - use current_dir as base
468        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, &current_dir)
478}
479
480/// Helper to validate a canonical path against the current directory
481fn validate_canonical_path(canonical: &Path, current_dir: &Path) -> Result<()> {
482    // Canonicalize current_dir for consistent comparison (Windows adds \\?\ prefix)
483    let current_dir_canonical = current_dir
484        .canonicalize()
485        .unwrap_or_else(|_| current_dir.to_path_buf());
486
487    // Ensure the path is within the current directory
488    if !canonical.starts_with(&current_dir_canonical) {
489        anyhow::bail!(
490            "Security error: attempted to access path outside of project directory: {}",
491            canonical.display()
492        );
493    }
494
495    // Check for sensitive files using shared path-component matcher
496    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    // --- parse_diff_line: producer/consumer co-locality ---
511
512    #[test]
513    fn parse_diff_line_classifies_each_marker() {
514        // Spot-check: a real generate_diff call exercises the parser end-
515        // to-end (next test). Here we hand-craft the format so we don't
516        // have to worry about the surrounding context lines.
517        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        // Context line: 3 spaces between the digit and content (no marker).
526        assert_eq!(parse_diff_line("   1   one"), DiffLineKind::Context);
527    }
528
529    #[test]
530    fn parse_diff_line_treats_malformed_as_context() {
531        // No leading digit, empty, marker without digit prefix — all fall
532        // through to Context so the renderer's match stays exhaustive.
533        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        // End-to-end: feed real generate_diff output through parse_diff_line
541        // and confirm the headers / context / +/- lines are classified as
542        // expected. Catches drift between the producer's `format!` and the
543        // parser's matchers.
544        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    /// generate_diff's output format is parsed back by
562    /// `src/tui/widgets/chat.rs::render_actions` via `parse_diff_line`,
563    /// which lives next to the markers and shares the same constants.
564    /// This test guards against silent drift: if the format changes, the
565    /// generator and parser share the same `pub const` so both update
566    /// together — but the constants must continue to appear in generated
567    /// output.
568    #[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    // Phase 2 Test Suite: Filesystem Operations - 10 comprehensive tests
589
590    #[test]
591    fn test_read_file_valid() {
592        // Test reading an existing file in the current project
593        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        // Test actual write + read roundtrip in target/ (always within project)
620        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        // Cleanup
630        let _ = fs::remove_file(test_path);
631        // Also clean up backup file
632        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        // Cleanup
653        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        // Cleanup
672        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        // .env.local, .env.production should be blocked
690        assert!(is_sensitive_path(Path::new("/project/.env.local")));
691        assert!(is_sensitive_path(Path::new("/project/.env.production")));
692        // But .environment.ts should NOT be blocked (path-component matching)
693        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        // Exact `.git/config` file is blocked.
726        assert!(is_sensitive_path(Path::new("/repo/.git/config")));
727        assert!(is_sensitive_path(Path::new(".git/config")));
728
729        // Nested `.git/config` (deeper than the repo root) still blocked.
730        assert!(is_sensitive_path(Path::new("/some/path/.git/config")));
731
732        // Files that merely START with `config` inside `.git/` must NOT be blocked.
733        // Previously the substring check `.git/config` matched all of these.
734        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        // Other `.git/` files unaffected.
741        assert!(!is_sensitive_path(Path::new("/repo/.git/HEAD")));
742        assert!(!is_sensitive_path(Path::new("/repo/.git/hooks/pre-commit")));
743
744        // A `config` file outside any `.git` directory is fine.
745        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        // Verify the expanded blocklist
752        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        // Docker creds still blocked via the .docker directory rule
761        // (not via a bare "config.json" filename match).
762        assert!(is_sensitive_path(Path::new(
763            "/home/user/.docker/config.json"
764        )));
765        assert!(is_sensitive_path(Path::new("/home/user/.netrc")));
766        // Mermaid config (contains cloud_api_key)
767        assert!(is_sensitive_path(Path::new(
768            "/home/user/.config/mermaid/config.toml"
769        )));
770        // But NOT arbitrary config.toml files in project directories
771        assert!(!is_sensitive_path(Path::new("/project/config.toml")));
772        // A bare config.json in a project directory must NOT be blocked —
773        // it's used by frontend tooling, editors, language servers, etc.
774        assert!(!is_sensitive_path(Path::new("/project/config.json")));
775        assert!(!is_sensitive_path(Path::new("/project/src/config.json")));
776    }
777}