Skip to main content

mermaid_cli/agents/
filesystem.rs

1use anyhow::{Context, Result};
2use base64::{engine::general_purpose, Engine as _};
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 || {
19        read_file(&path)
20    })
21    .await
22    .context("Failed to spawn blocking task for file read")?
23}
24
25/// Check if a file is a binary format that should be base64-encoded
26pub 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
39/// Read a binary file and encode it as base64
40pub fn read_binary_file(path: &str) -> Result<String> {
41    let path = normalize_path_for_read(path)?;
42
43    // Security check: block sensitive files but allow reading outside project
44    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
52/// Write content to a file atomically with timestamped backup
53pub fn write_file(path: &str, content: &str) -> Result<()> {
54    let path = normalize_path(path)?;
55
56    // Security check
57    validate_path(&path)?;
58
59    // Create parent directories if they don't exist
60    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    // Create timestamped backup if file exists
70    if path.exists() {
71        create_timestamped_backup(&path)?;
72    }
73
74    // Atomic write: write to temporary file, then rename
75    let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
76    let temp_path = std::path::PathBuf::from(&temp_path);
77
78    // Write to temporary file
79    fs::write(&temp_path, content).with_context(|| {
80        format!("Failed to write to temporary file: {}", temp_path.display())
81    })?;
82
83    // Atomically rename temp file to target
84    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
95/// Create a timestamped backup of a file
96/// Format: file.txt.backup.2025-10-20-01-45-32
97fn 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
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: write to temporary file, then rename
147    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    // Generate diff
163    let diff = generate_diff(&content, &new_content, old_string, new_string);
164    Ok(diff)
165}
166
167/// Generate a unified diff showing the changed lines with context
168fn 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    // Find where the change starts in the old content
176    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    // Context before
187    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    // Removed lines
194    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    // Added lines
202    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    // Context after
210    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
220/// Delete a file with timestamped backup (for recovery)
221pub fn delete_file(path: &str) -> Result<()> {
222    let path = normalize_path(path)?;
223
224    // Security check
225    validate_path(&path)?;
226
227    // Create timestamped backup before deletion
228    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
235/// Create a directory
236pub fn create_directory(path: &str) -> Result<()> {
237    let path = normalize_path(path)?;
238
239    // Security check
240    validate_path(&path)?;
241
242    fs::create_dir_all(&path)
243        .with_context(|| format!("Failed to create directory: {}", path.display()))
244}
245
246/// Normalize a path for reading (allows absolute paths anywhere)
247fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
248    let path = Path::new(path);
249
250    if path.is_absolute() {
251        // For absolute paths, return as-is (user has specified exact location)
252        Ok(path.to_path_buf())
253    } else {
254        // For relative paths, resolve from current directory
255        let current_dir = std::env::current_dir()?;
256        Ok(current_dir.join(path))
257    }
258}
259
260/// Normalize a path (resolve relative paths) - strict version for writes
261fn normalize_path(path: &str) -> Result<PathBuf> {
262    let path = Path::new(path);
263
264    if path.is_absolute() {
265        // For absolute paths, ensure they're within the current directory
266        let current_dir = std::env::current_dir()?;
267        if !path.starts_with(&current_dir) {
268            anyhow::bail!("Access denied: path outside of project directory");
269        }
270        Ok(path.to_path_buf())
271    } else {
272        // For relative paths, resolve from current directory
273        let current_dir = std::env::current_dir()?;
274        Ok(current_dir.join(path))
275    }
276}
277
278/// Check if a path component or filename matches a sensitive pattern.
279///
280/// Uses path-component matching (not substring) to avoid false positives
281/// like ".environment.ts" matching ".env". Checks both directory components
282/// and file extensions.
283fn is_sensitive_path(path: &Path) -> bool {
284    // Directory components that are always sensitive
285    let sensitive_dirs = [".ssh", ".aws", ".gnupg"];
286
287    // Filenames/extensions that are sensitive
288    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    // File extensions that are sensitive
302    let sensitive_extensions = ["pem", "key"];
303
304    let path_str = path.to_string_lossy();
305
306    // Check for .git/config specifically (substring is fine here, it's unique)
307    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        // Check sensitive directories
315        for dir in &sensitive_dirs {
316            if name == *dir {
317                return true;
318            }
319        }
320
321        // Check .env files: match ".env" exactly or ".env.*" (like .env.local, .env.production)
322        // but NOT files that merely contain "env" (like .environment.ts)
323        if name == ".env" || name.starts_with(".env.") {
324            return true;
325        }
326
327        // Check sensitive filenames
328        for filename in &sensitive_filenames {
329            if name == *filename {
330                return true;
331            }
332        }
333    }
334
335    // Check sensitive extensions
336    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
348/// Validate that a path is safe to read from (blocks sensitive files only)
349fn 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
359/// Validate that a path is safe to write to (strict - must be in project)
360fn validate_path(path: &Path) -> Result<()> {
361    let current_dir = std::env::current_dir()?;
362
363    // Resolve the path to handle .. and .
364    // For non-existent paths, walk up to find the first existing ancestor
365    let canonical = if path.exists() {
366        path.canonicalize()?
367    } else {
368        // Walk up the path to find the first existing ancestor
369        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                // Reached the root of a relative path
378                break;
379            }
380            if parent.exists() {
381                // Found existing ancestor - canonicalize it and join the rest
382                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, &current_dir);
387            }
388            current = parent;
389        }
390
391        // No existing ancestor found - use current_dir as base
392        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, &current_dir)
400}
401
402/// Helper to validate a canonical path against the current directory
403fn validate_canonical_path(canonical: &Path, current_dir: &Path) -> Result<()> {
404    // Canonicalize current_dir for consistent comparison (Windows adds \\?\ prefix)
405    let current_dir_canonical = current_dir.canonicalize().unwrap_or_else(|_| current_dir.to_path_buf());
406
407    // Ensure the path is within the current directory
408    if !canonical.starts_with(&current_dir_canonical) {
409        anyhow::bail!(
410            "Security error: attempted to access path outside of project directory: {}",
411            canonical.display()
412        );
413    }
414
415    // Check for sensitive files using shared path-component matcher
416    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    // Phase 2 Test Suite: Filesystem Operations - 10 comprehensive tests
431
432    #[test]
433    fn test_read_file_valid() {
434        // Test reading an existing file in the current project
435        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        // Test actual write + read roundtrip in target/ (always within project)
462        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        // Cleanup
472        let _ = fs::remove_file(test_path);
473        // Also clean up backup file
474        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        // Cleanup
495        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        // Cleanup
514        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        // .env.local, .env.production should be blocked
528        assert!(is_sensitive_path(Path::new("/project/.env.local")));
529        assert!(is_sensitive_path(Path::new("/project/.env.production")));
530        // But .environment.ts should NOT be blocked (path-component matching)
531        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        // Verify the expanded blocklist
554        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}