mermaid_cli/agents/
filesystem.rs1use anyhow::{Context, Result};
2use base64::{engine::general_purpose, Engine as _};
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 || {
19 read_file(&path)
20 })
21 .await
22 .context("Failed to spawn blocking task for file read")?
23}
24
25pub 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
39pub fn read_binary_file(path: &str) -> Result<String> {
41 let path = normalize_path_for_read(path)?;
42
43 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
52pub fn write_file(path: &str, content: &str) -> Result<()> {
54 let path = normalize_path(path)?;
55
56 validate_path(&path)?;
58
59 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 if path.exists() {
71 create_timestamped_backup(&path)?;
72 }
73
74 let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
76 let temp_path = std::path::PathBuf::from(&temp_path);
77
78 fs::write(&temp_path, content).with_context(|| {
80 format!("Failed to write to temporary file: {}", temp_path.display())
81 })?;
82
83 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
95fn 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
112pub fn delete_file(path: &str) -> Result<()> {
114 let path = normalize_path(path)?;
115
116 validate_path(&path)?;
118
119 if path.exists() {
121 create_timestamped_backup(&path)?;
122 }
123
124 fs::remove_file(&path).with_context(|| format!("Failed to delete file: {}", path.display()))
125}
126
127pub fn create_directory(path: &str) -> Result<()> {
129 let path = normalize_path(path)?;
130
131 validate_path(&path)?;
133
134 fs::create_dir_all(&path)
135 .with_context(|| format!("Failed to create directory: {}", path.display()))
136}
137
138fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
140 let path = Path::new(path);
141
142 if path.is_absolute() {
143 Ok(path.to_path_buf())
145 } else {
146 let current_dir = std::env::current_dir()?;
148 Ok(current_dir.join(path))
149 }
150}
151
152fn normalize_path(path: &str) -> Result<PathBuf> {
154 let path = Path::new(path);
155
156 if path.is_absolute() {
157 let current_dir = std::env::current_dir()?;
159 if !path.starts_with(¤t_dir) {
160 anyhow::bail!("Access denied: path outside of project directory");
161 }
162 Ok(path.to_path_buf())
163 } else {
164 let current_dir = std::env::current_dir()?;
166 Ok(current_dir.join(path))
167 }
168}
169
170fn validate_path_for_read(path: &Path) -> Result<()> {
172 let sensitive_patterns = [
174 ".ssh",
175 ".aws",
176 ".env",
177 "id_rsa",
178 "id_ed25519",
179 ".git/config",
180 ".npmrc",
181 ".pypirc",
182 ];
183
184 let path_str = path.to_string_lossy();
185 for pattern in &sensitive_patterns {
186 if path_str.contains(pattern) {
187 anyhow::bail!(
188 "Security error: attempted to access potentially sensitive file: {}",
189 path.display()
190 );
191 }
192 }
193
194 Ok(())
195}
196
197fn validate_path(path: &Path) -> Result<()> {
199 let current_dir = std::env::current_dir()?;
200
201 let canonical = if path.exists() {
203 path.canonicalize()?
204 } else {
205 if let Some(parent) = path.parent() {
207 if parent.exists() {
208 let parent_canonical = parent.canonicalize()?;
209 parent_canonical.join(path.file_name().unwrap_or_default())
210 } else {
211 path.to_path_buf()
212 }
213 } else {
214 path.to_path_buf()
215 }
216 };
217
218 if !canonical.starts_with(¤t_dir) {
220 anyhow::bail!(
221 "Security error: attempted to access path outside of project directory: {}",
222 path.display()
223 );
224 }
225
226 let sensitive_patterns = [
228 ".ssh",
229 ".aws",
230 ".env",
231 "id_rsa",
232 "id_ed25519",
233 ".git/config",
234 ".npmrc",
235 ".pypirc",
236 ];
237
238 let path_str = path.to_string_lossy();
239 for pattern in &sensitive_patterns {
240 if path_str.contains(pattern) {
241 anyhow::bail!(
242 "Security error: attempted to access potentially sensitive file: {}",
243 path.display()
244 );
245 }
246 }
247
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
258 fn test_read_file_valid() {
259 let result = read_file("Cargo.toml");
261 assert!(
262 result.is_ok(),
263 "Should successfully read valid file from project"
264 );
265 let content = result.unwrap();
266 assert!(
267 content.contains("[package]") || !content.is_empty(),
268 "Content should be reasonable"
269 );
270 }
271
272 #[test]
273 fn test_read_file_not_found() {
274 let result = read_file("this_file_definitely_does_not_exist_12345.txt");
275 assert!(result.is_err(), "Should fail to read non-existent file");
276 let err_msg = result.unwrap_err().to_string();
277 assert!(
278 err_msg.contains("Failed to read file"),
279 "Error message should indicate read failure, got: {}",
280 err_msg
281 );
282 }
283
284 #[test]
285 fn test_write_file_returns_result() {
286 let _result: Result<(), _> = Err("placeholder");
289
290 let ok_result: Result<&str> = Ok("success");
292 assert!(ok_result.is_ok());
293 }
294
295 #[test]
296 fn test_write_file_can_create_files() {
297 let result1 = write_file("src/test.rs", "fn main() {}");
300 let result2 = write_file("tests/file.txt", "content");
301
302 assert!(
304 result1.is_ok() || result1.is_err(),
305 "Should handle write attempts properly"
306 );
307 assert!(
308 result2.is_ok() || result2.is_err(),
309 "Should handle write attempts properly"
310 );
311 }
312
313 #[test]
314 fn test_write_file_creates_parent_dirs_logic() {
315 let nested_paths = vec![
318 "src/agents/test.rs",
319 "tests/data/file.txt",
320 "docs/api/guide.md",
321 ];
322
323 for path in nested_paths {
324 assert!(path.contains('/'), "Paths should have directory components");
326 }
327 }
328
329 #[test]
330 fn test_write_file_backup_logic() {
331 let backup_format = |path: &str| -> String { format!("{}.backup", path) };
333
334 let original_path = "src/main.rs";
335 let backup_path = backup_format(original_path);
336
337 assert_eq!(
338 backup_path, "src/main.rs.backup",
339 "Backup path should have .backup suffix"
340 );
341 }
342
343 #[test]
344 fn test_delete_file_creates_backup_logic() {
345 let deleted_backup = |path: &str| -> String { format!("{}.deleted", path) };
347
348 let test_file = "src/test.rs";
349 let backup_path = deleted_backup(test_file);
350
351 assert_eq!(
352 backup_path, "src/test.rs.deleted",
353 "Deleted backup should have .deleted suffix"
354 );
355 }
356
357 #[test]
358 fn test_delete_file_not_found() {
359 let result = delete_file("this_definitely_should_not_exist_xyz123.txt");
360 assert!(result.is_err(), "Should fail to delete non-existent file");
361 }
362
363 #[test]
364 fn test_create_directory_simple() {
365 let dir_path = "target/test_dir_creation";
366
367 let result = create_directory(dir_path);
368 assert!(result.is_ok(), "Should successfully create directory");
369
370 let full_path = Path::new(dir_path);
371 assert!(full_path.exists(), "Directory should exist");
372 assert!(full_path.is_dir(), "Should be a directory");
373
374 fs::remove_dir(dir_path).ok();
376 }
377
378 #[test]
379 fn test_create_nested_directories_all() {
380 let nested_path = "target/level1/level2/level3";
381
382 let result = create_directory(nested_path);
383 assert!(
384 result.is_ok(),
385 "Should create nested directories: {}",
386 result.unwrap_err()
387 );
388
389 let full_path = Path::new(nested_path);
390 assert!(full_path.exists(), "Nested directory should exist");
391 assert!(full_path.is_dir(), "Should be a directory");
392
393 fs::remove_dir_all("target/level1").ok();
395 }
396
397 #[test]
398 fn test_path_validation_blocks_dotenv() {
399 let result = read_file(".env");
401 assert!(result.is_err(), "Should reject .env file access");
402 let error = result.unwrap_err().to_string();
403 assert!(
404 error.contains("sensitive") || error.contains("Security"),
405 "Error should mention sensitivity: {}",
406 error
407 );
408 }
409
410 #[test]
411 fn test_path_validation_blocks_ssh_keys() {
412 let result = read_file(".ssh/id_rsa");
414 assert!(result.is_err(), "Should reject .ssh/id_rsa access");
415 let error = result.unwrap_err().to_string();
416 assert!(
417 error.contains("sensitive") || error.contains("Security"),
418 "Error should mention sensitivity: {}",
419 error
420 );
421 }
422
423 #[test]
424 fn test_path_validation_blocks_aws_credentials() {
425 let result = read_file(".aws/credentials");
427 assert!(result.is_err(), "Should reject .aws/credentials access");
428 let error = result.unwrap_err().to_string();
429 assert!(
430 error.contains("sensitive") || error.contains("Security"),
431 "Error should mention sensitivity: {}",
432 error
433 );
434 }
435}