Skip to main content

agnix_core/
fs.rs

1//! FileSystem abstraction for testability
2//!
3//! This module provides a `FileSystem` trait that abstracts file system operations,
4//! enabling validators to be unit tested with `MockFileSystem` instead of requiring
5//! real temp files.
6//!
7//! ## Usage
8//!
9//! For production code, use `RealFileSystem` which delegates to `std::fs` and
10//! the safe file reading utilities in `file_utils`.
11//!
12//! For tests, use `MockFileSystem` which provides an in-memory HashMap-based
13//! storage with `RwLock` for thread safety.
14//!
15//! ## Security
16//!
17//! ### Symlink Handling
18//!
19//! Both implementations reject symlinks in `read_to_string()` to prevent path
20//! traversal attacks. The `MockFileSystem` also implements depth limiting in
21//! `metadata()` and `canonicalize()` to detect symlink loops.
22//!
23//! ### TOCTOU (Time-of-Check-Time-of-Use)
24//!
25//! There is an inherent TOCTOU window between checking file properties and
26//! reading content. An attacker with local filesystem access could potentially
27//! replace a regular file with a symlink between the check and read operations.
28//! This is acceptable for a linter because:
29//!
30//! 1. The attack requires local filesystem access
31//! 2. The impact is limited to reading unexpected content
32//! 3. Eliminating TOCTOU entirely would require platform-specific APIs
33//!    (O_NOFOLLOW on Unix, FILE_FLAG_OPEN_REPARSE_POINT on Windows)
34//!
35//! For high-security environments, users should run agnix in a sandboxed
36//! environment or on trusted input only.
37//!
38//! ## Example
39//!
40//! ```rust,ignore
41//! use agnix_core::fs::{FileSystem, MockFileSystem, RealFileSystem};
42//! use std::path::Path;
43//!
44//! // In production code
45//! let fs = RealFileSystem;
46//! assert!(fs.exists(Path::new("Cargo.toml")));
47//!
48//! // In tests
49//! let mock_fs = MockFileSystem::new();
50//! mock_fs.add_file("/test/file.txt", "content");
51//! assert!(mock_fs.exists(Path::new("/test/file.txt")));
52//! ```
53
54use crate::diagnostics::{CoreError, FileError, LintResult};
55use std::collections::HashMap;
56use std::fs::Metadata;
57use std::io;
58use std::path::{Path, PathBuf};
59use std::sync::RwLock;
60
61/// Metadata information returned by the FileSystem trait.
62///
63/// This provides a subset of `std::fs::Metadata` that can be mocked.
64#[derive(Debug, Clone)]
65pub struct FileMetadata {
66    /// Whether this is a regular file
67    pub is_file: bool,
68    /// Whether this is a directory
69    pub is_dir: bool,
70    /// Whether this is a symlink
71    pub is_symlink: bool,
72    /// File size in bytes
73    pub len: u64,
74}
75
76impl FileMetadata {
77    /// Create metadata for a regular file
78    pub fn file(len: u64) -> Self {
79        Self {
80            is_file: true,
81            is_dir: false,
82            is_symlink: false,
83            len,
84        }
85    }
86
87    /// Create metadata for a directory
88    pub fn directory() -> Self {
89        Self {
90            is_file: false,
91            is_dir: true,
92            is_symlink: false,
93            len: 0,
94        }
95    }
96
97    /// Create metadata for a symlink
98    pub fn symlink() -> Self {
99        Self {
100            is_file: false,
101            is_dir: false,
102            is_symlink: true,
103            len: 0,
104        }
105    }
106}
107
108impl From<&Metadata> for FileMetadata {
109    fn from(meta: &Metadata) -> Self {
110        Self {
111            is_file: meta.is_file(),
112            is_dir: meta.is_dir(),
113            is_symlink: meta.file_type().is_symlink(),
114            len: meta.len(),
115        }
116    }
117}
118
119/// Directory entry returned by `read_dir`.
120#[derive(Debug, Clone)]
121pub struct DirEntry {
122    /// Path to this entry
123    pub path: PathBuf,
124    /// Metadata for this entry
125    pub metadata: FileMetadata,
126}
127
128/// Trait for abstracting file system operations.
129///
130/// This trait must be `Send + Sync` to support rayon parallel validation.
131/// It also requires `Debug` for use in config structs that derive Debug.
132pub trait FileSystem: Send + Sync + std::fmt::Debug {
133    /// Check if a path exists
134    fn exists(&self, path: &Path) -> bool;
135
136    /// Check if a path is a file
137    fn is_file(&self, path: &Path) -> bool;
138
139    /// Check if a path is a directory
140    fn is_dir(&self, path: &Path) -> bool;
141
142    /// Check if a path is a symlink
143    fn is_symlink(&self, path: &Path) -> bool;
144
145    /// Get metadata for a path (follows symlinks)
146    fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
147
148    /// Get metadata for a path without following symlinks
149    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
150
151    /// Read file contents to string (with security checks)
152    fn read_to_string(&self, path: &Path) -> LintResult<String>;
153
154    /// Write content to file (with security checks)
155    fn write(&self, path: &Path, content: &str) -> LintResult<()>;
156
157    /// Canonicalize a path
158    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
159
160    /// Read directory contents
161    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
162}
163
164/// Real file system implementation that delegates to `std::fs` and `file_utils`.
165#[derive(Debug, Clone, Copy, Default)]
166pub struct RealFileSystem;
167
168impl FileSystem for RealFileSystem {
169    fn exists(&self, path: &Path) -> bool {
170        path.exists()
171    }
172
173    fn is_file(&self, path: &Path) -> bool {
174        path.is_file()
175    }
176
177    fn is_dir(&self, path: &Path) -> bool {
178        path.is_dir()
179    }
180
181    fn is_symlink(&self, path: &Path) -> bool {
182        path.is_symlink()
183    }
184
185    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
186        std::fs::metadata(path).map(|m| FileMetadata::from(&m))
187    }
188
189    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
190        std::fs::symlink_metadata(path).map(|m| FileMetadata::from(&m))
191    }
192
193    fn read_to_string(&self, path: &Path) -> LintResult<String> {
194        crate::file_utils::safe_read_file(path)
195    }
196
197    fn write(&self, path: &Path, content: &str) -> LintResult<()> {
198        crate::file_utils::safe_write_file(path, content)
199    }
200
201    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
202        std::fs::canonicalize(path)
203    }
204
205    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
206        Ok(std::fs::read_dir(path)?
207            .filter_map(|entry_res| {
208                // Skip entries that fail to read (permission denied, etc.)
209                // This matches the previous AS-015 behavior of tolerating bad entries
210                let entry = entry_res.ok()?;
211                let path = entry.path();
212                // Use symlink_metadata to avoid following symlinks
213                // Skip entries where metadata fails (transient errors)
214                let metadata = std::fs::symlink_metadata(&path).ok()?;
215                Some(DirEntry {
216                    path,
217                    metadata: FileMetadata::from(&metadata),
218                })
219            })
220            .collect())
221    }
222}
223
224/// Mock entry type for the in-memory file system.
225#[derive(Debug, Clone)]
226enum MockEntry {
227    File { content: String },
228    Directory,
229    Symlink { target: PathBuf },
230}
231
232/// Mock file system for testing.
233///
234/// Provides an in-memory HashMap-based storage with `RwLock` for thread safety.
235/// This enables unit testing validators without requiring real temp files.
236#[derive(Debug, Default)]
237pub struct MockFileSystem {
238    entries: RwLock<HashMap<PathBuf, MockEntry>>,
239}
240
241impl MockFileSystem {
242    /// Create a new empty mock file system
243    pub fn new() -> Self {
244        Self {
245            entries: RwLock::new(HashMap::new()),
246        }
247    }
248
249    /// Add a file with the given content
250    pub fn add_file(&self, path: impl AsRef<Path>, content: impl Into<String>) {
251        let path = normalize_mock_path(path.as_ref());
252        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
253        entries.insert(
254            path,
255            MockEntry::File {
256                content: content.into(),
257            },
258        );
259    }
260
261    /// Add a directory
262    pub fn add_dir(&self, path: impl AsRef<Path>) {
263        let path = normalize_mock_path(path.as_ref());
264        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
265        entries.insert(path, MockEntry::Directory);
266    }
267
268    /// Add a symlink pointing to target
269    pub fn add_symlink(&self, path: impl AsRef<Path>, target: impl AsRef<Path>) {
270        let path = normalize_mock_path(path.as_ref());
271        let target = normalize_mock_path(target.as_ref());
272        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
273        entries.insert(path, MockEntry::Symlink { target });
274    }
275
276    /// Remove an entry
277    pub fn remove(&self, path: impl AsRef<Path>) {
278        let path = normalize_mock_path(path.as_ref());
279        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
280        entries.remove(&path);
281    }
282
283    /// Clear all entries
284    pub fn clear(&self) {
285        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
286        entries.clear();
287    }
288
289    fn get_entry(&self, path: &Path) -> Option<MockEntry> {
290        let path = normalize_mock_path(path);
291        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
292        entries.get(&path).cloned()
293    }
294
295    fn resolve_symlink(&self, path: &Path) -> Option<PathBuf> {
296        let path = normalize_mock_path(path);
297        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
298        match entries.get(&path) {
299            Some(MockEntry::Symlink { target }) => Some(target.clone()),
300            _ => None,
301        }
302    }
303
304    /// Maximum depth for symlink resolution to prevent infinite loops.
305    ///
306    /// This matches the typical OS limit (Linux ELOOP is triggered at 40 levels).
307    /// Value chosen to match POSIX `SYMLOOP_MAX` and Linux's internal limit.
308    /// See: https://man7.org/linux/man-pages/man3/fpathconf.3.html
309    pub const MAX_SYMLINK_DEPTH: u32 = 40;
310
311    /// Internal helper for metadata with depth tracking
312    fn metadata_with_depth(&self, path: &Path, depth: u32) -> io::Result<FileMetadata> {
313        if depth > Self::MAX_SYMLINK_DEPTH {
314            return Err(io::Error::other("too many levels of symbolic links"));
315        }
316
317        // Follow symlinks - use an enum to handle the result outside the lock
318        enum MetaResult {
319            Found(FileMetadata),
320            FollowSymlink(PathBuf),
321        }
322
323        let path = normalize_mock_path(path);
324
325        let result: io::Result<MetaResult> = {
326            let entries = self.entries.read().expect("MockFileSystem lock poisoned");
327            match entries.get(&path) {
328                None => Err(io::Error::new(
329                    io::ErrorKind::NotFound,
330                    format!("path not found: {}", path.display()),
331                )),
332                Some(MockEntry::File { content }) => {
333                    Ok(MetaResult::Found(FileMetadata::file(content.len() as u64)))
334                }
335                Some(MockEntry::Directory) => Ok(MetaResult::Found(FileMetadata::directory())),
336                Some(MockEntry::Symlink { target }) => {
337                    Ok(MetaResult::FollowSymlink(target.clone()))
338                }
339            }
340        };
341
342        match result? {
343            MetaResult::Found(meta) => Ok(meta),
344            MetaResult::FollowSymlink(target) => self.metadata_with_depth(&target, depth + 1),
345        }
346    }
347
348    /// Internal helper for canonicalize with depth tracking
349    fn canonicalize_with_depth(&self, path: &Path, depth: u32) -> io::Result<PathBuf> {
350        if depth > Self::MAX_SYMLINK_DEPTH {
351            return Err(io::Error::other("too many levels of symbolic links"));
352        }
353
354        let path_normalized = normalize_mock_path(path);
355
356        if !self.exists(&path_normalized) {
357            return Err(io::Error::new(
358                io::ErrorKind::NotFound,
359                format!("path not found: {}", path.display()),
360            ));
361        }
362
363        // Follow symlinks if present
364        if let Some(target) = self.resolve_symlink(&path_normalized) {
365            self.canonicalize_with_depth(&target, depth + 1)
366        } else {
367            Ok(path_normalized)
368        }
369    }
370}
371
372/// Normalize a path for mock file system storage.
373/// Converts backslashes to forward slashes for cross-platform consistency.
374fn normalize_mock_path(path: &Path) -> PathBuf {
375    let path_str = path.to_string_lossy();
376    PathBuf::from(path_str.replace('\\', "/"))
377}
378
379impl FileSystem for MockFileSystem {
380    fn exists(&self, path: &Path) -> bool {
381        self.get_entry(path).is_some()
382    }
383
384    fn is_file(&self, path: &Path) -> bool {
385        matches!(self.get_entry(path), Some(MockEntry::File { .. }))
386    }
387
388    fn is_dir(&self, path: &Path) -> bool {
389        matches!(self.get_entry(path), Some(MockEntry::Directory))
390    }
391
392    fn is_symlink(&self, path: &Path) -> bool {
393        matches!(self.get_entry(path), Some(MockEntry::Symlink { .. }))
394    }
395
396    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
397        self.metadata_with_depth(path, 0)
398    }
399
400    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
401        // Don't follow symlinks
402        let entry = self.get_entry(path).ok_or_else(|| {
403            io::Error::new(
404                io::ErrorKind::NotFound,
405                format!("path not found: {}", path.display()),
406            )
407        })?;
408
409        match entry {
410            MockEntry::File { content } => Ok(FileMetadata::file(content.len() as u64)),
411            MockEntry::Directory => Ok(FileMetadata::directory()),
412            MockEntry::Symlink { .. } => Ok(FileMetadata::symlink()),
413        }
414    }
415
416    fn read_to_string(&self, path: &Path) -> LintResult<String> {
417        let path_normalized = normalize_mock_path(path);
418        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
419
420        let entry = entries.get(&path_normalized).ok_or_else(|| {
421            CoreError::File(FileError::Read {
422                path: path.to_path_buf(),
423                source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
424            })
425        })?;
426
427        match entry {
428            MockEntry::File { content } => Ok(content.clone()),
429            MockEntry::Directory => Err(CoreError::File(FileError::NotRegular {
430                path: path.to_path_buf(),
431            })),
432            MockEntry::Symlink { target } => {
433                // Follow symlink to target (mirrors real fs::metadata behavior)
434                let target_entry = entries.get(target).ok_or_else(|| {
435                    CoreError::File(FileError::Read {
436                        path: path.to_path_buf(),
437                        source: io::Error::new(io::ErrorKind::NotFound, "symlink target not found"),
438                    })
439                })?;
440                match target_entry {
441                    MockEntry::File { content } => Ok(content.clone()),
442                    _ => Err(CoreError::File(FileError::NotRegular {
443                        path: path.to_path_buf(),
444                    })),
445                }
446            }
447        }
448    }
449
450    fn write(&self, path: &Path, content: &str) -> LintResult<()> {
451        let path_normalized = normalize_mock_path(path);
452        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
453
454        // Check if path exists and is valid for writing
455        match entries.get(&path_normalized) {
456            Some(MockEntry::File { .. }) => {
457                // Overwrite existing file
458                entries.insert(
459                    path_normalized,
460                    MockEntry::File {
461                        content: content.to_string(),
462                    },
463                );
464                Ok(())
465            }
466            Some(MockEntry::Directory) => Err(CoreError::File(FileError::NotRegular {
467                path: path.to_path_buf(),
468            })),
469            Some(MockEntry::Symlink { .. }) => Err(CoreError::File(FileError::Symlink {
470                path: path.to_path_buf(),
471            })),
472            None => {
473                // File doesn't exist - error like safe_write_file
474                Err(CoreError::File(FileError::Write {
475                    path: path.to_path_buf(),
476                    source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
477                }))
478            }
479        }
480    }
481
482    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
483        self.canonicalize_with_depth(path, 0)
484    }
485
486    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
487        let path_normalized = normalize_mock_path(path);
488
489        // Check if it's a directory
490        match self.get_entry(&path_normalized) {
491            Some(MockEntry::Directory) => {}
492            Some(_) => {
493                return Err(io::Error::new(
494                    io::ErrorKind::NotADirectory,
495                    "not a directory",
496                ));
497            }
498            None => {
499                return Err(io::Error::new(
500                    io::ErrorKind::NotFound,
501                    "directory not found",
502                ));
503            }
504        }
505
506        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
507        let mut result = Vec::new();
508
509        // Normalize the path string for prefix matching
510        let prefix = if path_normalized.to_string_lossy().ends_with('/') {
511            path_normalized.to_string_lossy().to_string()
512        } else {
513            format!("{}/", path_normalized.display())
514        };
515
516        for (entry_path, entry) in entries.iter() {
517            let entry_str = entry_path.to_string_lossy();
518
519            // Check if this entry is a direct child of the directory
520            if let Some(rest) = entry_str.strip_prefix(&prefix) {
521                // Only include direct children (no further slashes)
522                if !rest.contains('/') && !rest.is_empty() {
523                    let metadata = match entry {
524                        MockEntry::File { content } => FileMetadata::file(content.len() as u64),
525                        MockEntry::Directory => FileMetadata::directory(),
526                        MockEntry::Symlink { .. } => FileMetadata::symlink(),
527                    };
528                    result.push(DirEntry {
529                        path: entry_path.clone(),
530                        metadata,
531                    });
532                }
533            }
534        }
535
536        Ok(result)
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    // ===== RealFileSystem tests =====
545
546    #[test]
547    fn test_real_fs_exists() {
548        let fs = RealFileSystem;
549        // Cargo.toml should exist in the project root
550        assert!(fs.exists(Path::new("Cargo.toml")));
551        assert!(!fs.exists(Path::new("nonexistent_file_xyz.txt")));
552    }
553
554    #[test]
555    fn test_real_fs_is_file() {
556        let fs = RealFileSystem;
557        assert!(fs.is_file(Path::new("Cargo.toml")));
558        assert!(!fs.is_file(Path::new("src")));
559    }
560
561    #[test]
562    fn test_real_fs_is_dir() {
563        let fs = RealFileSystem;
564        assert!(fs.is_dir(Path::new("src")));
565        assert!(!fs.is_dir(Path::new("Cargo.toml")));
566    }
567
568    #[test]
569    fn test_real_fs_read_to_string() {
570        let fs = RealFileSystem;
571        let content = fs.read_to_string(Path::new("Cargo.toml"));
572        assert!(content.is_ok());
573        assert!(content.unwrap().contains("[package]"));
574    }
575
576    #[test]
577    fn test_real_fs_read_nonexistent() {
578        let fs = RealFileSystem;
579        let result = fs.read_to_string(Path::new("nonexistent_file_xyz.txt"));
580        assert!(result.is_err());
581    }
582
583    // ===== MockFileSystem tests =====
584
585    #[test]
586    fn test_mock_fs_add_and_exists() {
587        let fs = MockFileSystem::new();
588        assert!(!fs.exists(Path::new("/test/file.txt")));
589
590        fs.add_file("/test/file.txt", "content");
591        assert!(fs.exists(Path::new("/test/file.txt")));
592    }
593
594    #[test]
595    fn test_mock_fs_is_file() {
596        let fs = MockFileSystem::new();
597        fs.add_file("/test/file.txt", "content");
598        fs.add_dir("/test/dir");
599
600        assert!(fs.is_file(Path::new("/test/file.txt")));
601        assert!(!fs.is_file(Path::new("/test/dir")));
602    }
603
604    #[test]
605    fn test_mock_fs_is_dir() {
606        let fs = MockFileSystem::new();
607        fs.add_file("/test/file.txt", "content");
608        fs.add_dir("/test/dir");
609
610        assert!(!fs.is_dir(Path::new("/test/file.txt")));
611        assert!(fs.is_dir(Path::new("/test/dir")));
612    }
613
614    #[test]
615    fn test_mock_fs_is_symlink() {
616        let fs = MockFileSystem::new();
617        fs.add_file("/test/file.txt", "content");
618        fs.add_symlink("/test/link.txt", "/test/file.txt");
619
620        assert!(!fs.is_symlink(Path::new("/test/file.txt")));
621        assert!(fs.is_symlink(Path::new("/test/link.txt")));
622    }
623
624    #[test]
625    fn test_mock_fs_read_to_string() {
626        let fs = MockFileSystem::new();
627        fs.add_file("/test/file.txt", "hello world");
628
629        let content = fs.read_to_string(Path::new("/test/file.txt"));
630        assert!(content.is_ok());
631        assert_eq!(content.unwrap(), "hello world");
632    }
633
634    #[test]
635    fn test_mock_fs_read_nonexistent() {
636        let fs = MockFileSystem::new();
637        let result = fs.read_to_string(Path::new("/test/file.txt"));
638        assert!(result.is_err());
639    }
640
641    #[test]
642    fn test_mock_fs_read_directory_fails() {
643        let fs = MockFileSystem::new();
644        fs.add_dir("/test/dir");
645
646        let result = fs.read_to_string(Path::new("/test/dir"));
647        assert!(matches!(
648            result,
649            Err(CoreError::File(FileError::NotRegular { .. }))
650        ));
651    }
652
653    #[test]
654    fn test_mock_fs_read_symlink_follows_target() {
655        let fs = MockFileSystem::new();
656        fs.add_file("/test/file.txt", "content");
657        fs.add_symlink("/test/link.txt", "/test/file.txt");
658
659        let result = fs.read_to_string(Path::new("/test/link.txt"));
660        assert!(result.is_ok());
661        assert_eq!(result.unwrap(), "content");
662    }
663
664    #[test]
665    fn test_mock_fs_write() {
666        let fs = MockFileSystem::new();
667        fs.add_file("/test/file.txt", "original");
668
669        let result = fs.write(Path::new("/test/file.txt"), "updated");
670        assert!(result.is_ok());
671
672        let content = fs.read_to_string(Path::new("/test/file.txt")).unwrap();
673        assert_eq!(content, "updated");
674    }
675
676    #[test]
677    fn test_mock_fs_write_nonexistent_fails() {
678        let fs = MockFileSystem::new();
679
680        let result = fs.write(Path::new("/test/file.txt"), "content");
681        assert!(matches!(
682            result,
683            Err(CoreError::File(FileError::Write { .. }))
684        ));
685    }
686
687    #[test]
688    fn test_mock_fs_metadata_file() {
689        let fs = MockFileSystem::new();
690        fs.add_file("/test/file.txt", "12345");
691
692        let meta = fs.metadata(Path::new("/test/file.txt")).unwrap();
693        assert!(meta.is_file);
694        assert!(!meta.is_dir);
695        assert!(!meta.is_symlink);
696        assert_eq!(meta.len, 5);
697    }
698
699    #[test]
700    fn test_mock_fs_metadata_directory() {
701        let fs = MockFileSystem::new();
702        fs.add_dir("/test/dir");
703
704        let meta = fs.metadata(Path::new("/test/dir")).unwrap();
705        assert!(!meta.is_file);
706        assert!(meta.is_dir);
707        assert!(!meta.is_symlink);
708    }
709
710    #[test]
711    fn test_mock_fs_symlink_metadata() {
712        let fs = MockFileSystem::new();
713        fs.add_file("/test/file.txt", "content");
714        fs.add_symlink("/test/link.txt", "/test/file.txt");
715
716        // symlink_metadata should not follow symlinks
717        let meta = fs.symlink_metadata(Path::new("/test/link.txt")).unwrap();
718        assert!(meta.is_symlink);
719
720        // metadata should follow symlinks
721        let meta = fs.metadata(Path::new("/test/link.txt")).unwrap();
722        assert!(meta.is_file);
723        assert!(!meta.is_symlink);
724    }
725
726    #[test]
727    fn test_mock_fs_read_dir() {
728        let fs = MockFileSystem::new();
729        fs.add_dir("/test");
730        fs.add_file("/test/file1.txt", "content1");
731        fs.add_file("/test/file2.txt", "content2");
732        fs.add_dir("/test/subdir");
733
734        let entries = fs.read_dir(Path::new("/test")).unwrap();
735        assert_eq!(entries.len(), 3);
736
737        let names: Vec<_> = entries
738            .iter()
739            .map(|e| e.path.file_name().unwrap().to_string_lossy().to_string())
740            .collect();
741        assert!(names.contains(&"file1.txt".to_string()));
742        assert!(names.contains(&"file2.txt".to_string()));
743        assert!(names.contains(&"subdir".to_string()));
744    }
745
746    #[test]
747    fn test_mock_fs_read_dir_nonexistent() {
748        let fs = MockFileSystem::new();
749        let result = fs.read_dir(Path::new("/nonexistent"));
750        assert!(result.is_err());
751    }
752
753    #[test]
754    fn test_mock_fs_read_dir_not_directory() {
755        let fs = MockFileSystem::new();
756        fs.add_file("/test/file.txt", "content");
757
758        let result = fs.read_dir(Path::new("/test/file.txt"));
759        assert!(result.is_err());
760    }
761
762    #[test]
763    fn test_mock_fs_canonicalize() {
764        let fs = MockFileSystem::new();
765        fs.add_file("/test/file.txt", "content");
766
767        let canonical = fs.canonicalize(Path::new("/test/file.txt")).unwrap();
768        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
769    }
770
771    #[test]
772    fn test_mock_fs_canonicalize_follows_symlink() {
773        let fs = MockFileSystem::new();
774        fs.add_file("/test/file.txt", "content");
775        fs.add_symlink("/test/link.txt", "/test/file.txt");
776
777        let canonical = fs.canonicalize(Path::new("/test/link.txt")).unwrap();
778        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
779    }
780
781    #[test]
782    fn test_mock_fs_clear() {
783        let fs = MockFileSystem::new();
784        fs.add_file("/test/file.txt", "content");
785        assert!(fs.exists(Path::new("/test/file.txt")));
786
787        fs.clear();
788        assert!(!fs.exists(Path::new("/test/file.txt")));
789    }
790
791    #[test]
792    fn test_mock_fs_remove() {
793        let fs = MockFileSystem::new();
794        fs.add_file("/test/file.txt", "content");
795        assert!(fs.exists(Path::new("/test/file.txt")));
796
797        fs.remove("/test/file.txt");
798        assert!(!fs.exists(Path::new("/test/file.txt")));
799    }
800
801    #[test]
802    fn test_mock_fs_windows_path_normalization() {
803        let fs = MockFileSystem::new();
804        fs.add_file("C:/test/file.txt", "content");
805
806        // Should work with either path separator
807        assert!(fs.exists(Path::new("C:/test/file.txt")));
808        assert!(fs.exists(Path::new("C:\\test\\file.txt")));
809    }
810
811    #[test]
812    fn test_mock_fs_thread_safety() {
813        use std::sync::Arc;
814        use std::thread;
815
816        let fs = Arc::new(MockFileSystem::new());
817        let mut handles = vec![];
818
819        // Spawn multiple threads that read and write
820        for i in 0..10 {
821            let fs_clone = Arc::clone(&fs);
822            let handle = thread::spawn(move || {
823                let path = format!("/test/file{}.txt", i);
824                fs_clone.add_file(&path, format!("content{}", i));
825                assert!(fs_clone.exists(Path::new(&path)));
826                let content = fs_clone.read_to_string(Path::new(&path)).unwrap();
827                assert_eq!(content, format!("content{}", i));
828            });
829            handles.push(handle);
830        }
831
832        for handle in handles {
833            handle.join().unwrap();
834        }
835
836        // Verify all files exist
837        for i in 0..10 {
838            let path = format!("/test/file{}.txt", i);
839            assert!(fs.exists(Path::new(&path)));
840        }
841    }
842
843    #[test]
844    fn test_mock_fs_circular_symlink_metadata() {
845        let fs = MockFileSystem::new();
846        // Create circular symlinks: a -> b -> a
847        fs.add_symlink("/test/a", "/test/b");
848        fs.add_symlink("/test/b", "/test/a");
849
850        // metadata() follows symlinks and should detect the cycle
851        let result = fs.metadata(Path::new("/test/a"));
852        assert!(result.is_err());
853        assert!(
854            result
855                .unwrap_err()
856                .to_string()
857                .contains("too many levels of symbolic links")
858        );
859    }
860
861    #[test]
862    fn test_mock_fs_circular_symlink_canonicalize() {
863        let fs = MockFileSystem::new();
864        // Create circular symlinks: a -> b -> a
865        fs.add_symlink("/test/a", "/test/b");
866        fs.add_symlink("/test/b", "/test/a");
867
868        // canonicalize() follows symlinks and should detect the cycle
869        let result = fs.canonicalize(Path::new("/test/a"));
870        assert!(result.is_err());
871        assert!(
872            result
873                .unwrap_err()
874                .to_string()
875                .contains("too many levels of symbolic links")
876        );
877    }
878
879    #[test]
880    fn test_mock_fs_chained_symlinks() {
881        let fs = MockFileSystem::new();
882        // Create chain: link1 -> link2 -> link3 -> file
883        fs.add_file("/test/file.txt", "content");
884        fs.add_symlink("/test/link3", "/test/file.txt");
885        fs.add_symlink("/test/link2", "/test/link3");
886        fs.add_symlink("/test/link1", "/test/link2");
887
888        // metadata() should follow the chain and return file metadata
889        let meta = fs.metadata(Path::new("/test/link1")).unwrap();
890        assert!(meta.is_file);
891        assert_eq!(meta.len, 7); // "content".len()
892
893        // canonicalize() should return the final target
894        let canonical = fs.canonicalize(Path::new("/test/link1")).unwrap();
895        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
896    }
897
898    #[test]
899    fn test_mock_fs_max_symlink_depth_boundary() {
900        // Test that we can handle chains up to MAX_SYMLINK_DEPTH
901        let fs = MockFileSystem::new();
902        fs.add_file("/test/target.txt", "content");
903
904        // Create a chain of exactly MAX_SYMLINK_DEPTH links
905        let mut prev = PathBuf::from("/test/target.txt");
906        for i in 0..MockFileSystem::MAX_SYMLINK_DEPTH {
907            let link = PathBuf::from(format!("/test/link{}", i));
908            fs.add_symlink(&link, &prev);
909            prev = link;
910        }
911
912        // Should succeed at the boundary
913        let result = fs.metadata(&prev);
914        assert!(result.is_ok(), "Should handle MAX_SYMLINK_DEPTH links");
915    }
916
917    #[test]
918    fn test_mock_fs_exceeds_max_symlink_depth() {
919        // Test that MAX_SYMLINK_DEPTH + 1 links fails
920        let fs = MockFileSystem::new();
921        fs.add_file("/test/target.txt", "content");
922
923        // Create a chain of MAX_SYMLINK_DEPTH + 1 links
924        let mut prev = PathBuf::from("/test/target.txt");
925        for i in 0..=MockFileSystem::MAX_SYMLINK_DEPTH {
926            let link = PathBuf::from(format!("/test/link{}", i));
927            fs.add_symlink(&link, &prev);
928            prev = link;
929        }
930
931        // Should fail beyond the limit
932        let result = fs.metadata(&prev);
933        assert!(
934            result.is_err(),
935            "Should fail when exceeding MAX_SYMLINK_DEPTH"
936        );
937        assert!(
938            result
939                .unwrap_err()
940                .to_string()
941                .contains("too many levels of symbolic links")
942        );
943    }
944
945    // ===== Unix-specific symlink tests for RealFileSystem =====
946
947    #[cfg(unix)]
948    mod unix_tests {
949        use super::*;
950        use std::os::unix::fs::symlink;
951        use tempfile::TempDir;
952
953        #[test]
954        fn test_real_fs_follows_symlink_read() {
955            let temp = TempDir::new().unwrap();
956            let target = temp.path().join("target.txt");
957            let link = temp.path().join("link.txt");
958
959            std::fs::write(&target, "content").unwrap();
960            symlink(&target, &link).unwrap();
961
962            let fs = RealFileSystem;
963            let result = fs.read_to_string(&link);
964
965            assert!(result.is_ok());
966            assert_eq!(result.unwrap(), "content");
967        }
968
969        #[test]
970        fn test_real_fs_symlink_metadata() {
971            let temp = TempDir::new().unwrap();
972            let target = temp.path().join("target.txt");
973            let link = temp.path().join("link.txt");
974
975            std::fs::write(&target, "content").unwrap();
976            symlink(&target, &link).unwrap();
977
978            let fs = RealFileSystem;
979
980            // symlink_metadata should show symlink
981            let meta = fs.symlink_metadata(&link).unwrap();
982            assert!(meta.is_symlink);
983
984            // metadata follows symlink and shows file
985            let meta = fs.metadata(&link).unwrap();
986            assert!(meta.is_file);
987            assert!(!meta.is_symlink);
988        }
989
990        #[test]
991        fn test_real_fs_dangling_symlink() {
992            let temp = TempDir::new().unwrap();
993            let link = temp.path().join("dangling.txt");
994
995            symlink("/nonexistent/target", &link).unwrap();
996
997            let fs = RealFileSystem;
998            let result = fs.read_to_string(&link);
999
1000            // Dangling symlinks produce a read error (target not found)
1001            assert!(result.is_err());
1002            assert!(matches!(
1003                result.unwrap_err(),
1004                CoreError::File(FileError::Read { .. })
1005            ));
1006        }
1007
1008        #[test]
1009        fn test_real_fs_is_symlink() {
1010            let temp = TempDir::new().unwrap();
1011            let target = temp.path().join("target.txt");
1012            let link = temp.path().join("link.txt");
1013
1014            std::fs::write(&target, "content").unwrap();
1015            symlink(&target, &link).unwrap();
1016
1017            let fs = RealFileSystem;
1018
1019            assert!(!fs.is_symlink(&target));
1020            assert!(fs.is_symlink(&link));
1021        }
1022
1023        #[test]
1024        fn test_real_fs_read_dir_skips_symlinks_in_metadata() {
1025            let temp = TempDir::new().unwrap();
1026
1027            // Create a regular file
1028            std::fs::write(temp.path().join("file.txt"), "content").unwrap();
1029
1030            // Create a symlink
1031            symlink(temp.path().join("file.txt"), temp.path().join("link.txt")).unwrap();
1032
1033            let fs = RealFileSystem;
1034            let entries = fs.read_dir(temp.path()).unwrap();
1035
1036            // Both should be returned
1037            assert_eq!(entries.len(), 2);
1038
1039            // But the symlink should have is_symlink = true in metadata
1040            let symlink_entry = entries
1041                .iter()
1042                .find(|e| e.path.file_name().unwrap().to_str().unwrap() == "link.txt");
1043            assert!(symlink_entry.is_some());
1044            assert!(symlink_entry.unwrap().metadata.is_symlink);
1045
1046            // And the file should have is_file = true
1047            let file_entry = entries
1048                .iter()
1049                .find(|e| e.path.file_name().unwrap().to_str().unwrap() == "file.txt");
1050            assert!(file_entry.is_some());
1051            assert!(file_entry.unwrap().metadata.is_file);
1052        }
1053    }
1054}