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/// Read a file from the filesystem
7pub fn read_file(path: &str) -> Result<String> {
8    let path = normalize_path_for_read(path)?;
9
10    // Security check: block sensitive files but allow reading outside project
11    validate_path_for_read(&path)?;
12
13    fs::read_to_string(&path).with_context(|| format!("Failed to read file: {}", path.display()))
14}
15
16/// Read a file from the filesystem asynchronously (for parallel operations)
17pub 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
23/// Check if a file is a binary format that should be base64-encoded
24pub 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
37/// Read a binary file and encode it as base64
38pub fn read_binary_file(path: &str) -> Result<String> {
39    let path = normalize_path_for_read(path)?;
40
41    // Security check: block sensitive files but allow reading outside project
42    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
50/// Write content to a file atomically with timestamped backup
51pub fn write_file(path: &str, content: &str) -> Result<()> {
52    let path = normalize_path(path)?;
53
54    // Security check
55    validate_path(&path)?;
56
57    // Create parent directories if they don't exist
58    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    // Create timestamped backup if file exists
68    if path.exists() {
69        create_timestamped_backup(&path)?;
70    }
71
72    atomic_write(&path, content)
73}
74
75/// Create a timestamped backup of a file
76/// Format: file.txt.backup.2025-10-20-01-45-32
77fn 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
92/// Atomically write content to a file by writing to a temporary file first,
93/// then renaming. This prevents partial writes if the process is interrupted.
94fn 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
112/// Edit a file by replacing a unique occurrence of old_string with new_string
113/// Returns a unified diff showing the changes
114pub fn edit_file(path: &str, old_string: &str, new_string: &str) -> Result<String> {
115    let path = normalize_path(path)?;
116
117    // Security check
118    validate_path(&path)?;
119
120    // Read current content
121    let content = fs::read_to_string(&path)
122        .with_context(|| format!("Failed to read file for editing: {}", path.display()))?;
123
124    // Check that old_string occurs exactly once
125    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    // Perform the replacement
141    let new_content = content.replacen(old_string, new_string, 1);
142
143    // Create timestamped backup
144    create_timestamped_backup(&path)?;
145
146    atomic_write(&path, &new_content)?;
147
148    // Generate diff
149    let diff = generate_diff(&content, &new_content, old_string, new_string);
150    Ok(diff)
151}
152
153/// Generate a unified diff showing the changed lines with context
154fn 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    // Find where the change starts in the old content
167    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    // Context before
181    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    // Removed lines
188    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    // Added lines
196    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    // Context after
204    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
214/// Delete a file with timestamped backup (for recovery)
215pub fn delete_file(path: &str) -> Result<()> {
216    let path = normalize_path(path)?;
217
218    // Security check
219    validate_path(&path)?;
220
221    // Create timestamped backup before deletion
222    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
229/// Create a directory
230pub fn create_directory(path: &str) -> Result<()> {
231    let path = normalize_path(path)?;
232
233    // Security check
234    validate_path(&path)?;
235
236    fs::create_dir_all(&path)
237        .with_context(|| format!("Failed to create directory: {}", path.display()))
238}
239
240/// Normalize a path for reading (allows absolute paths anywhere)
241fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
242    let path = Path::new(path);
243
244    if path.is_absolute() {
245        // For absolute paths, return as-is (user has specified exact location)
246        Ok(path.to_path_buf())
247    } else {
248        // For relative paths, resolve from current directory
249        let current_dir = std::env::current_dir()?;
250        Ok(current_dir.join(path))
251    }
252}
253
254/// Normalize a path (resolve relative paths) - strict version for writes
255fn normalize_path(path: &str) -> Result<PathBuf> {
256    let path = Path::new(path);
257
258    // Reject paths containing ".." to prevent directory traversal.
259    // Symlinks in existing ancestors are resolved by canonicalize() in validate_path,
260    // but ".." in non-existent portions would be silently dropped by file_name().
261    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        // For absolute paths, ensure they're within the current directory
269        let current_dir = std::env::current_dir()?;
270        if !path.starts_with(&current_dir) {
271            anyhow::bail!("Access denied: path outside of project directory");
272        }
273        Ok(path.to_path_buf())
274    } else {
275        // For relative paths, resolve from current directory
276        let current_dir = std::env::current_dir()?;
277        Ok(current_dir.join(path))
278    }
279}
280
281/// Check if a path component or filename matches a sensitive pattern.
282///
283/// Uses path-component matching (not substring) to avoid false positives
284/// like ".environment.ts" matching ".env". Checks both directory components
285/// and file extensions.
286fn is_sensitive_path(path: &Path) -> bool {
287    // Directory components that are always sensitive
288    let sensitive_dirs = [".ssh", ".aws", ".gnupg", ".docker"];
289
290    // Filenames/extensions that are sensitive
291    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", // Docker registry auth, various credential stores
304    ];
305
306    // File extensions that are sensitive
307    let sensitive_extensions = ["pem", "key"];
308
309    let path_str = path.to_string_lossy();
310
311    // Check for .git/config specifically (substring is fine here, it's unique)
312    if path_str.contains(".git/config") || path_str.contains(".git\\config") {
313        return true;
314    }
315
316    // Check for mermaid config (contains cloud_api_key)
317    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        // Check sensitive directories
327        for dir in &sensitive_dirs {
328            if name == *dir {
329                return true;
330            }
331        }
332
333        // Check .env files: match ".env" exactly or ".env.*" (like .env.local, .env.production)
334        // but NOT files that merely contain "env" (like .environment.ts)
335        if name == ".env" || name.starts_with(".env.") {
336            return true;
337        }
338
339        // Check sensitive filenames
340        for filename in &sensitive_filenames {
341            if name == *filename {
342                return true;
343            }
344        }
345    }
346
347    // Check sensitive extensions
348    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
360/// Validate that a path is safe to read from (blocks sensitive files only)
361fn 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
371/// Validate that a path is safe to write to (strict - must be in project)
372fn validate_path(path: &Path) -> Result<()> {
373    let current_dir = std::env::current_dir()?;
374
375    // Resolve the path to handle .. and .
376    // For non-existent paths, walk up to find the first existing ancestor
377    let canonical = if path.exists() {
378        path.canonicalize()?
379    } else {
380        // Walk up the path to find the first existing ancestor
381        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                // Reached the root of a relative path
390                break;
391            }
392            if parent.exists() {
393                // Found existing ancestor - canonicalize it and join the rest
394                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, &current_dir);
399            }
400            current = parent;
401        }
402
403        // No existing ancestor found - use current_dir as base
404        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, &current_dir)
414}
415
416/// Helper to validate a canonical path against the current directory
417fn validate_canonical_path(canonical: &Path, current_dir: &Path) -> Result<()> {
418    // Canonicalize current_dir for consistent comparison (Windows adds \\?\ prefix)
419    let current_dir_canonical = current_dir
420        .canonicalize()
421        .unwrap_or_else(|_| current_dir.to_path_buf());
422
423    // Ensure the path is within the current directory
424    if !canonical.starts_with(&current_dir_canonical) {
425        anyhow::bail!(
426            "Security error: attempted to access path outside of project directory: {}",
427            canonical.display()
428        );
429    }
430
431    // Check for sensitive files using shared path-component matcher
432    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    // Phase 2 Test Suite: Filesystem Operations - 10 comprehensive tests
447
448    #[test]
449    fn test_read_file_valid() {
450        // Test reading an existing file in the current project
451        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        // Test actual write + read roundtrip in target/ (always within project)
478        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        // Cleanup
488        let _ = fs::remove_file(test_path);
489        // Also clean up backup file
490        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        // Cleanup
511        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        // Cleanup
530        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        // .env.local, .env.production should be blocked
548        assert!(is_sensitive_path(Path::new("/project/.env.local")));
549        assert!(is_sensitive_path(Path::new("/project/.env.production")));
550        // But .environment.ts should NOT be blocked (path-component matching)
551        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        // Verify the expanded blocklist
584        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        // Docker and netrc
593        assert!(is_sensitive_path(Path::new(
594            "/home/user/.docker/config.json"
595        )));
596        assert!(is_sensitive_path(Path::new("/home/user/.netrc")));
597        // Mermaid config (contains cloud_api_key)
598        assert!(is_sensitive_path(Path::new(
599            "/home/user/.config/mermaid/config.toml"
600        )));
601        // But NOT arbitrary config.toml files in project directories
602        assert!(!is_sensitive_path(Path::new("/project/config.toml")));
603    }
604}