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 async fn read_binary_file_async(path: String) -> Result<String> {
54 tokio::task::spawn_blocking(move || {
55 read_binary_file(&path)
56 })
57 .await
58 .context("Failed to spawn blocking task for binary file read")?
59}
60
61pub fn write_file(path: &str, content: &str) -> Result<()> {
63 let path = normalize_path(path)?;
64
65 validate_path(&path)?;
67
68 if let Some(parent) = path.parent() {
70 fs::create_dir_all(parent).with_context(|| {
71 format!(
72 "Failed to create parent directories for: {}",
73 path.display()
74 )
75 })?;
76 }
77
78 if path.exists() {
80 create_timestamped_backup(&path)?;
81 }
82
83 let temp_path = format!("{}.tmp.{}", path.display(), std::process::id());
85 let temp_path = std::path::PathBuf::from(&temp_path);
86
87 fs::write(&temp_path, content).with_context(|| {
89 format!("Failed to write to temporary file: {}", temp_path.display())
90 })?;
91
92 fs::rename(&temp_path, &path).with_context(|| {
94 format!(
95 "Failed to finalize write to: {} (temp file: {})",
96 path.display(),
97 temp_path.display()
98 )
99 })?;
100
101 Ok(())
102}
103
104fn create_timestamped_backup(path: &std::path::Path) -> Result<()> {
107 let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S");
108 let backup_path = format!("{}.backup.{}", path.display(), timestamp);
109
110 fs::copy(path, &backup_path).with_context(|| {
111 format!(
112 "Failed to create backup of: {} to {}",
113 path.display(),
114 backup_path
115 )
116 })?;
117
118 Ok(())
119}
120
121pub fn delete_file(path: &str) -> Result<()> {
123 let path = normalize_path(path)?;
124
125 validate_path(&path)?;
127
128 if path.exists() {
130 create_timestamped_backup(&path)?;
131 }
132
133 fs::remove_file(&path).with_context(|| format!("Failed to delete file: {}", path.display()))
134}
135
136pub fn create_directory(path: &str) -> Result<()> {
138 let path = normalize_path(path)?;
139
140 validate_path(&path)?;
142
143 fs::create_dir_all(&path)
144 .with_context(|| format!("Failed to create directory: {}", path.display()))
145}
146
147fn normalize_path_for_read(path: &str) -> Result<PathBuf> {
149 let path = Path::new(path);
150
151 if path.is_absolute() {
152 Ok(path.to_path_buf())
154 } else {
155 let current_dir = std::env::current_dir()?;
157 Ok(current_dir.join(path))
158 }
159}
160
161fn normalize_path(path: &str) -> Result<PathBuf> {
163 let path = Path::new(path);
164
165 if path.is_absolute() {
166 let current_dir = std::env::current_dir()?;
168 if !path.starts_with(¤t_dir) {
169 anyhow::bail!("Access denied: path outside of project directory");
170 }
171 Ok(path.to_path_buf())
172 } else {
173 let current_dir = std::env::current_dir()?;
175 Ok(current_dir.join(path))
176 }
177}
178
179fn validate_path_for_read(path: &Path) -> Result<()> {
181 let sensitive_patterns = [
183 ".ssh",
184 ".aws",
185 ".env",
186 "id_rsa",
187 "id_ed25519",
188 ".git/config",
189 ".npmrc",
190 ".pypirc",
191 ];
192
193 let path_str = path.to_string_lossy();
194 for pattern in &sensitive_patterns {
195 if path_str.contains(pattern) {
196 anyhow::bail!(
197 "Security error: attempted to access potentially sensitive file: {}",
198 path.display()
199 );
200 }
201 }
202
203 Ok(())
204}
205
206fn validate_path(path: &Path) -> Result<()> {
208 let current_dir = std::env::current_dir()?;
209
210 let canonical = if path.exists() {
212 path.canonicalize()?
213 } else {
214 if let Some(parent) = path.parent() {
216 if parent.exists() {
217 let parent_canonical = parent.canonicalize()?;
218 parent_canonical.join(path.file_name().unwrap_or_default())
219 } else {
220 path.to_path_buf()
221 }
222 } else {
223 path.to_path_buf()
224 }
225 };
226
227 if !canonical.starts_with(¤t_dir) {
229 anyhow::bail!(
230 "Security error: attempted to access path outside of project directory: {}",
231 path.display()
232 );
233 }
234
235 let sensitive_patterns = [
237 ".ssh",
238 ".aws",
239 ".env",
240 "id_rsa",
241 "id_ed25519",
242 ".git/config",
243 ".npmrc",
244 ".pypirc",
245 ];
246
247 let path_str = path.to_string_lossy();
248 for pattern in &sensitive_patterns {
249 if path_str.contains(pattern) {
250 anyhow::bail!(
251 "Security error: attempted to access potentially sensitive file: {}",
252 path.display()
253 );
254 }
255 }
256
257 Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use tempfile::TempDir;
264
265 #[test]
268 fn test_read_file_valid() {
269 let result = read_file("Cargo.toml");
271 assert!(
272 result.is_ok(),
273 "Should successfully read valid file from project"
274 );
275 let content = result.unwrap();
276 assert!(
277 content.contains("[package]") || !content.is_empty(),
278 "Content should be reasonable"
279 );
280 }
281
282 #[test]
283 fn test_read_file_not_found() {
284 let result = read_file("this_file_definitely_does_not_exist_12345.txt");
285 assert!(result.is_err(), "Should fail to read non-existent file");
286 let err_msg = result.unwrap_err().to_string();
287 assert!(
288 err_msg.contains("Failed to read file"),
289 "Error message should indicate read failure, got: {}",
290 err_msg
291 );
292 }
293
294 #[test]
295 fn test_write_file_returns_result() {
296 let _result: Result<(), _> = Err("placeholder");
299
300 let ok_result: Result<&str> = Ok("success");
302 assert!(ok_result.is_ok());
303 }
304
305 #[test]
306 fn test_write_file_can_create_files() {
307 let result1 = write_file("src/test.rs", "fn main() {}");
310 let result2 = write_file("tests/file.txt", "content");
311
312 assert!(
314 result1.is_ok() || result1.is_err(),
315 "Should handle write attempts properly"
316 );
317 assert!(
318 result2.is_ok() || result2.is_err(),
319 "Should handle write attempts properly"
320 );
321 }
322
323 #[test]
324 fn test_write_file_creates_parent_dirs_logic() {
325 let nested_paths = vec![
328 "src/agents/test.rs",
329 "tests/data/file.txt",
330 "docs/api/guide.md",
331 ];
332
333 for path in nested_paths {
334 assert!(path.contains('/'), "Paths should have directory components");
336 }
337 }
338
339 #[test]
340 fn test_write_file_backup_logic() {
341 let backup_format = |path: &str| -> String { format!("{}.backup", path) };
343
344 let original_path = "src/main.rs";
345 let backup_path = backup_format(original_path);
346
347 assert_eq!(
348 backup_path, "src/main.rs.backup",
349 "Backup path should have .backup suffix"
350 );
351 }
352
353 #[test]
354 fn test_delete_file_creates_backup_logic() {
355 let deleted_backup = |path: &str| -> String { format!("{}.deleted", path) };
357
358 let test_file = "src/test.rs";
359 let backup_path = deleted_backup(test_file);
360
361 assert_eq!(
362 backup_path, "src/test.rs.deleted",
363 "Deleted backup should have .deleted suffix"
364 );
365 }
366
367 #[test]
368 fn test_delete_file_not_found() {
369 let result = delete_file("this_definitely_should_not_exist_xyz123.txt");
370 assert!(result.is_err(), "Should fail to delete non-existent file");
371 }
372
373 #[test]
374 fn test_create_directory_simple() {
375 let dir_path = "target/test_dir_creation";
376
377 let result = create_directory(dir_path);
378 assert!(result.is_ok(), "Should successfully create directory");
379
380 let full_path = Path::new(dir_path);
381 assert!(full_path.exists(), "Directory should exist");
382 assert!(full_path.is_dir(), "Should be a directory");
383
384 fs::remove_dir(dir_path).ok();
386 }
387
388 #[test]
389 fn test_create_nested_directories_all() {
390 let nested_path = "target/level1/level2/level3";
391
392 let result = create_directory(nested_path);
393 assert!(
394 result.is_ok(),
395 "Should create nested directories: {}",
396 result.unwrap_err()
397 );
398
399 let full_path = Path::new(nested_path);
400 assert!(full_path.exists(), "Nested directory should exist");
401 assert!(full_path.is_dir(), "Should be a directory");
402
403 fs::remove_dir_all("target/level1").ok();
405 }
406
407 #[test]
408 fn test_path_validation_blocks_dotenv() {
409 let result = read_file(".env");
411 assert!(result.is_err(), "Should reject .env file access");
412 let error = result.unwrap_err().to_string();
413 assert!(
414 error.contains("sensitive") || error.contains("Security"),
415 "Error should mention sensitivity: {}",
416 error
417 );
418 }
419
420 #[test]
421 fn test_path_validation_blocks_ssh_keys() {
422 let result = read_file(".ssh/id_rsa");
424 assert!(result.is_err(), "Should reject .ssh/id_rsa access");
425 let error = result.unwrap_err().to_string();
426 assert!(
427 error.contains("sensitive") || error.contains("Security"),
428 "Error should mention sensitivity: {}",
429 error
430 );
431 }
432
433 #[test]
434 fn test_path_validation_blocks_aws_credentials() {
435 let result = read_file(".aws/credentials");
437 assert!(result.is_err(), "Should reject .aws/credentials access");
438 let error = result.unwrap_err().to_string();
439 assert!(
440 error.contains("sensitive") || error.contains("Security"),
441 "Error should mention sensitivity: {}",
442 error
443 );
444 }
445}