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::{LintError, 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
421            .get(&path_normalized)
422            .ok_or_else(|| LintError::FileRead {
423                path: path.to_path_buf(),
424                source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
425            })?;
426
427        match entry {
428            MockEntry::File { content } => Ok(content.clone()),
429            MockEntry::Directory => Err(LintError::FileNotRegular {
430                path: path.to_path_buf(),
431            }),
432            MockEntry::Symlink { .. } => Err(LintError::FileSymlink {
433                path: path.to_path_buf(),
434            }),
435        }
436    }
437
438    fn write(&self, path: &Path, content: &str) -> LintResult<()> {
439        let path_normalized = normalize_mock_path(path);
440        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
441
442        // Check if path exists and is valid for writing
443        match entries.get(&path_normalized) {
444            Some(MockEntry::File { .. }) => {
445                // Overwrite existing file
446                entries.insert(
447                    path_normalized,
448                    MockEntry::File {
449                        content: content.to_string(),
450                    },
451                );
452                Ok(())
453            }
454            Some(MockEntry::Directory) => Err(LintError::FileNotRegular {
455                path: path.to_path_buf(),
456            }),
457            Some(MockEntry::Symlink { .. }) => Err(LintError::FileSymlink {
458                path: path.to_path_buf(),
459            }),
460            None => {
461                // File doesn't exist - error like safe_write_file
462                Err(LintError::FileWrite {
463                    path: path.to_path_buf(),
464                    source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
465                })
466            }
467        }
468    }
469
470    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
471        self.canonicalize_with_depth(path, 0)
472    }
473
474    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
475        let path_normalized = normalize_mock_path(path);
476
477        // Check if it's a directory
478        match self.get_entry(&path_normalized) {
479            Some(MockEntry::Directory) => {}
480            Some(_) => {
481                return Err(io::Error::new(
482                    io::ErrorKind::NotADirectory,
483                    "not a directory",
484                ));
485            }
486            None => {
487                return Err(io::Error::new(
488                    io::ErrorKind::NotFound,
489                    "directory not found",
490                ));
491            }
492        }
493
494        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
495        let mut result = Vec::new();
496
497        // Normalize the path string for prefix matching
498        let prefix = if path_normalized.to_string_lossy().ends_with('/') {
499            path_normalized.to_string_lossy().to_string()
500        } else {
501            format!("{}/", path_normalized.display())
502        };
503
504        for (entry_path, entry) in entries.iter() {
505            let entry_str = entry_path.to_string_lossy();
506
507            // Check if this entry is a direct child of the directory
508            if let Some(rest) = entry_str.strip_prefix(&prefix) {
509                // Only include direct children (no further slashes)
510                if !rest.contains('/') && !rest.is_empty() {
511                    let metadata = match entry {
512                        MockEntry::File { content } => FileMetadata::file(content.len() as u64),
513                        MockEntry::Directory => FileMetadata::directory(),
514                        MockEntry::Symlink { .. } => FileMetadata::symlink(),
515                    };
516                    result.push(DirEntry {
517                        path: entry_path.clone(),
518                        metadata,
519                    });
520                }
521            }
522        }
523
524        Ok(result)
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    // ===== RealFileSystem tests =====
533
534    #[test]
535    fn test_real_fs_exists() {
536        let fs = RealFileSystem;
537        // Cargo.toml should exist in the project root
538        assert!(fs.exists(Path::new("Cargo.toml")));
539        assert!(!fs.exists(Path::new("nonexistent_file_xyz.txt")));
540    }
541
542    #[test]
543    fn test_real_fs_is_file() {
544        let fs = RealFileSystem;
545        assert!(fs.is_file(Path::new("Cargo.toml")));
546        assert!(!fs.is_file(Path::new("src")));
547    }
548
549    #[test]
550    fn test_real_fs_is_dir() {
551        let fs = RealFileSystem;
552        assert!(fs.is_dir(Path::new("src")));
553        assert!(!fs.is_dir(Path::new("Cargo.toml")));
554    }
555
556    #[test]
557    fn test_real_fs_read_to_string() {
558        let fs = RealFileSystem;
559        let content = fs.read_to_string(Path::new("Cargo.toml"));
560        assert!(content.is_ok());
561        assert!(content.unwrap().contains("[package]"));
562    }
563
564    #[test]
565    fn test_real_fs_read_nonexistent() {
566        let fs = RealFileSystem;
567        let result = fs.read_to_string(Path::new("nonexistent_file_xyz.txt"));
568        assert!(result.is_err());
569    }
570
571    // ===== MockFileSystem tests =====
572
573    #[test]
574    fn test_mock_fs_add_and_exists() {
575        let fs = MockFileSystem::new();
576        assert!(!fs.exists(Path::new("/test/file.txt")));
577
578        fs.add_file("/test/file.txt", "content");
579        assert!(fs.exists(Path::new("/test/file.txt")));
580    }
581
582    #[test]
583    fn test_mock_fs_is_file() {
584        let fs = MockFileSystem::new();
585        fs.add_file("/test/file.txt", "content");
586        fs.add_dir("/test/dir");
587
588        assert!(fs.is_file(Path::new("/test/file.txt")));
589        assert!(!fs.is_file(Path::new("/test/dir")));
590    }
591
592    #[test]
593    fn test_mock_fs_is_dir() {
594        let fs = MockFileSystem::new();
595        fs.add_file("/test/file.txt", "content");
596        fs.add_dir("/test/dir");
597
598        assert!(!fs.is_dir(Path::new("/test/file.txt")));
599        assert!(fs.is_dir(Path::new("/test/dir")));
600    }
601
602    #[test]
603    fn test_mock_fs_is_symlink() {
604        let fs = MockFileSystem::new();
605        fs.add_file("/test/file.txt", "content");
606        fs.add_symlink("/test/link.txt", "/test/file.txt");
607
608        assert!(!fs.is_symlink(Path::new("/test/file.txt")));
609        assert!(fs.is_symlink(Path::new("/test/link.txt")));
610    }
611
612    #[test]
613    fn test_mock_fs_read_to_string() {
614        let fs = MockFileSystem::new();
615        fs.add_file("/test/file.txt", "hello world");
616
617        let content = fs.read_to_string(Path::new("/test/file.txt"));
618        assert!(content.is_ok());
619        assert_eq!(content.unwrap(), "hello world");
620    }
621
622    #[test]
623    fn test_mock_fs_read_nonexistent() {
624        let fs = MockFileSystem::new();
625        let result = fs.read_to_string(Path::new("/test/file.txt"));
626        assert!(result.is_err());
627    }
628
629    #[test]
630    fn test_mock_fs_read_directory_fails() {
631        let fs = MockFileSystem::new();
632        fs.add_dir("/test/dir");
633
634        let result = fs.read_to_string(Path::new("/test/dir"));
635        assert!(matches!(result, Err(LintError::FileNotRegular { .. })));
636    }
637
638    #[test]
639    fn test_mock_fs_read_symlink_fails() {
640        let fs = MockFileSystem::new();
641        fs.add_file("/test/file.txt", "content");
642        fs.add_symlink("/test/link.txt", "/test/file.txt");
643
644        let result = fs.read_to_string(Path::new("/test/link.txt"));
645        assert!(matches!(result, Err(LintError::FileSymlink { .. })));
646    }
647
648    #[test]
649    fn test_mock_fs_write() {
650        let fs = MockFileSystem::new();
651        fs.add_file("/test/file.txt", "original");
652
653        let result = fs.write(Path::new("/test/file.txt"), "updated");
654        assert!(result.is_ok());
655
656        let content = fs.read_to_string(Path::new("/test/file.txt")).unwrap();
657        assert_eq!(content, "updated");
658    }
659
660    #[test]
661    fn test_mock_fs_write_nonexistent_fails() {
662        let fs = MockFileSystem::new();
663
664        let result = fs.write(Path::new("/test/file.txt"), "content");
665        assert!(matches!(result, Err(LintError::FileWrite { .. })));
666    }
667
668    #[test]
669    fn test_mock_fs_metadata_file() {
670        let fs = MockFileSystem::new();
671        fs.add_file("/test/file.txt", "12345");
672
673        let meta = fs.metadata(Path::new("/test/file.txt")).unwrap();
674        assert!(meta.is_file);
675        assert!(!meta.is_dir);
676        assert!(!meta.is_symlink);
677        assert_eq!(meta.len, 5);
678    }
679
680    #[test]
681    fn test_mock_fs_metadata_directory() {
682        let fs = MockFileSystem::new();
683        fs.add_dir("/test/dir");
684
685        let meta = fs.metadata(Path::new("/test/dir")).unwrap();
686        assert!(!meta.is_file);
687        assert!(meta.is_dir);
688        assert!(!meta.is_symlink);
689    }
690
691    #[test]
692    fn test_mock_fs_symlink_metadata() {
693        let fs = MockFileSystem::new();
694        fs.add_file("/test/file.txt", "content");
695        fs.add_symlink("/test/link.txt", "/test/file.txt");
696
697        // symlink_metadata should not follow symlinks
698        let meta = fs.symlink_metadata(Path::new("/test/link.txt")).unwrap();
699        assert!(meta.is_symlink);
700
701        // metadata should follow symlinks
702        let meta = fs.metadata(Path::new("/test/link.txt")).unwrap();
703        assert!(meta.is_file);
704        assert!(!meta.is_symlink);
705    }
706
707    #[test]
708    fn test_mock_fs_read_dir() {
709        let fs = MockFileSystem::new();
710        fs.add_dir("/test");
711        fs.add_file("/test/file1.txt", "content1");
712        fs.add_file("/test/file2.txt", "content2");
713        fs.add_dir("/test/subdir");
714
715        let entries = fs.read_dir(Path::new("/test")).unwrap();
716        assert_eq!(entries.len(), 3);
717
718        let names: Vec<_> = entries
719            .iter()
720            .map(|e| e.path.file_name().unwrap().to_string_lossy().to_string())
721            .collect();
722        assert!(names.contains(&"file1.txt".to_string()));
723        assert!(names.contains(&"file2.txt".to_string()));
724        assert!(names.contains(&"subdir".to_string()));
725    }
726
727    #[test]
728    fn test_mock_fs_read_dir_nonexistent() {
729        let fs = MockFileSystem::new();
730        let result = fs.read_dir(Path::new("/nonexistent"));
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_mock_fs_read_dir_not_directory() {
736        let fs = MockFileSystem::new();
737        fs.add_file("/test/file.txt", "content");
738
739        let result = fs.read_dir(Path::new("/test/file.txt"));
740        assert!(result.is_err());
741    }
742
743    #[test]
744    fn test_mock_fs_canonicalize() {
745        let fs = MockFileSystem::new();
746        fs.add_file("/test/file.txt", "content");
747
748        let canonical = fs.canonicalize(Path::new("/test/file.txt")).unwrap();
749        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
750    }
751
752    #[test]
753    fn test_mock_fs_canonicalize_follows_symlink() {
754        let fs = MockFileSystem::new();
755        fs.add_file("/test/file.txt", "content");
756        fs.add_symlink("/test/link.txt", "/test/file.txt");
757
758        let canonical = fs.canonicalize(Path::new("/test/link.txt")).unwrap();
759        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
760    }
761
762    #[test]
763    fn test_mock_fs_clear() {
764        let fs = MockFileSystem::new();
765        fs.add_file("/test/file.txt", "content");
766        assert!(fs.exists(Path::new("/test/file.txt")));
767
768        fs.clear();
769        assert!(!fs.exists(Path::new("/test/file.txt")));
770    }
771
772    #[test]
773    fn test_mock_fs_remove() {
774        let fs = MockFileSystem::new();
775        fs.add_file("/test/file.txt", "content");
776        assert!(fs.exists(Path::new("/test/file.txt")));
777
778        fs.remove("/test/file.txt");
779        assert!(!fs.exists(Path::new("/test/file.txt")));
780    }
781
782    #[test]
783    fn test_mock_fs_windows_path_normalization() {
784        let fs = MockFileSystem::new();
785        fs.add_file("C:/test/file.txt", "content");
786
787        // Should work with either path separator
788        assert!(fs.exists(Path::new("C:/test/file.txt")));
789        assert!(fs.exists(Path::new("C:\\test\\file.txt")));
790    }
791
792    #[test]
793    fn test_mock_fs_thread_safety() {
794        use std::sync::Arc;
795        use std::thread;
796
797        let fs = Arc::new(MockFileSystem::new());
798        let mut handles = vec![];
799
800        // Spawn multiple threads that read and write
801        for i in 0..10 {
802            let fs_clone = Arc::clone(&fs);
803            let handle = thread::spawn(move || {
804                let path = format!("/test/file{}.txt", i);
805                fs_clone.add_file(&path, format!("content{}", i));
806                assert!(fs_clone.exists(Path::new(&path)));
807                let content = fs_clone.read_to_string(Path::new(&path)).unwrap();
808                assert_eq!(content, format!("content{}", i));
809            });
810            handles.push(handle);
811        }
812
813        for handle in handles {
814            handle.join().unwrap();
815        }
816
817        // Verify all files exist
818        for i in 0..10 {
819            let path = format!("/test/file{}.txt", i);
820            assert!(fs.exists(Path::new(&path)));
821        }
822    }
823
824    #[test]
825    fn test_mock_fs_circular_symlink_metadata() {
826        let fs = MockFileSystem::new();
827        // Create circular symlinks: a -> b -> a
828        fs.add_symlink("/test/a", "/test/b");
829        fs.add_symlink("/test/b", "/test/a");
830
831        // metadata() follows symlinks and should detect the cycle
832        let result = fs.metadata(Path::new("/test/a"));
833        assert!(result.is_err());
834        assert!(
835            result
836                .unwrap_err()
837                .to_string()
838                .contains("too many levels of symbolic links")
839        );
840    }
841
842    #[test]
843    fn test_mock_fs_circular_symlink_canonicalize() {
844        let fs = MockFileSystem::new();
845        // Create circular symlinks: a -> b -> a
846        fs.add_symlink("/test/a", "/test/b");
847        fs.add_symlink("/test/b", "/test/a");
848
849        // canonicalize() follows symlinks and should detect the cycle
850        let result = fs.canonicalize(Path::new("/test/a"));
851        assert!(result.is_err());
852        assert!(
853            result
854                .unwrap_err()
855                .to_string()
856                .contains("too many levels of symbolic links")
857        );
858    }
859
860    #[test]
861    fn test_mock_fs_chained_symlinks() {
862        let fs = MockFileSystem::new();
863        // Create chain: link1 -> link2 -> link3 -> file
864        fs.add_file("/test/file.txt", "content");
865        fs.add_symlink("/test/link3", "/test/file.txt");
866        fs.add_symlink("/test/link2", "/test/link3");
867        fs.add_symlink("/test/link1", "/test/link2");
868
869        // metadata() should follow the chain and return file metadata
870        let meta = fs.metadata(Path::new("/test/link1")).unwrap();
871        assert!(meta.is_file);
872        assert_eq!(meta.len, 7); // "content".len()
873
874        // canonicalize() should return the final target
875        let canonical = fs.canonicalize(Path::new("/test/link1")).unwrap();
876        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
877    }
878
879    #[test]
880    fn test_mock_fs_max_symlink_depth_boundary() {
881        // Test that we can handle chains up to MAX_SYMLINK_DEPTH
882        let fs = MockFileSystem::new();
883        fs.add_file("/test/target.txt", "content");
884
885        // Create a chain of exactly MAX_SYMLINK_DEPTH links
886        let mut prev = PathBuf::from("/test/target.txt");
887        for i in 0..MockFileSystem::MAX_SYMLINK_DEPTH {
888            let link = PathBuf::from(format!("/test/link{}", i));
889            fs.add_symlink(&link, &prev);
890            prev = link;
891        }
892
893        // Should succeed at the boundary
894        let result = fs.metadata(&prev);
895        assert!(result.is_ok(), "Should handle MAX_SYMLINK_DEPTH links");
896    }
897
898    #[test]
899    fn test_mock_fs_exceeds_max_symlink_depth() {
900        // Test that MAX_SYMLINK_DEPTH + 1 links fails
901        let fs = MockFileSystem::new();
902        fs.add_file("/test/target.txt", "content");
903
904        // Create a chain of MAX_SYMLINK_DEPTH + 1 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 fail beyond the limit
913        let result = fs.metadata(&prev);
914        assert!(
915            result.is_err(),
916            "Should fail when exceeding MAX_SYMLINK_DEPTH"
917        );
918        assert!(
919            result
920                .unwrap_err()
921                .to_string()
922                .contains("too many levels of symbolic links")
923        );
924    }
925
926    // ===== Unix-specific symlink tests for RealFileSystem =====
927
928    #[cfg(unix)]
929    mod unix_tests {
930        use super::*;
931        use std::os::unix::fs::symlink;
932        use tempfile::TempDir;
933
934        #[test]
935        fn test_real_fs_rejects_symlink_read() {
936            let temp = TempDir::new().unwrap();
937            let target = temp.path().join("target.txt");
938            let link = temp.path().join("link.txt");
939
940            std::fs::write(&target, "content").unwrap();
941            symlink(&target, &link).unwrap();
942
943            let fs = RealFileSystem;
944            let result = fs.read_to_string(&link);
945
946            assert!(result.is_err());
947            assert!(matches!(result.unwrap_err(), LintError::FileSymlink { .. }));
948        }
949
950        #[test]
951        fn test_real_fs_symlink_metadata() {
952            let temp = TempDir::new().unwrap();
953            let target = temp.path().join("target.txt");
954            let link = temp.path().join("link.txt");
955
956            std::fs::write(&target, "content").unwrap();
957            symlink(&target, &link).unwrap();
958
959            let fs = RealFileSystem;
960
961            // symlink_metadata should show symlink
962            let meta = fs.symlink_metadata(&link).unwrap();
963            assert!(meta.is_symlink);
964
965            // metadata follows symlink and shows file
966            let meta = fs.metadata(&link).unwrap();
967            assert!(meta.is_file);
968            assert!(!meta.is_symlink);
969        }
970
971        #[test]
972        fn test_real_fs_dangling_symlink() {
973            let temp = TempDir::new().unwrap();
974            let link = temp.path().join("dangling.txt");
975
976            symlink("/nonexistent/target", &link).unwrap();
977
978            let fs = RealFileSystem;
979            let result = fs.read_to_string(&link);
980
981            // Should reject as symlink (caught before we try to read nonexistent target)
982            assert!(result.is_err());
983            assert!(matches!(result.unwrap_err(), LintError::FileSymlink { .. }));
984        }
985
986        #[test]
987        fn test_real_fs_is_symlink() {
988            let temp = TempDir::new().unwrap();
989            let target = temp.path().join("target.txt");
990            let link = temp.path().join("link.txt");
991
992            std::fs::write(&target, "content").unwrap();
993            symlink(&target, &link).unwrap();
994
995            let fs = RealFileSystem;
996
997            assert!(!fs.is_symlink(&target));
998            assert!(fs.is_symlink(&link));
999        }
1000
1001        #[test]
1002        fn test_real_fs_read_dir_skips_symlinks_in_metadata() {
1003            let temp = TempDir::new().unwrap();
1004
1005            // Create a regular file
1006            std::fs::write(temp.path().join("file.txt"), "content").unwrap();
1007
1008            // Create a symlink
1009            symlink(temp.path().join("file.txt"), temp.path().join("link.txt")).unwrap();
1010
1011            let fs = RealFileSystem;
1012            let entries = fs.read_dir(temp.path()).unwrap();
1013
1014            // Both should be returned
1015            assert_eq!(entries.len(), 2);
1016
1017            // But the symlink should have is_symlink = true in metadata
1018            let symlink_entry = entries
1019                .iter()
1020                .find(|e| e.path.file_name().unwrap().to_str().unwrap() == "link.txt");
1021            assert!(symlink_entry.is_some());
1022            assert!(symlink_entry.unwrap().metadata.is_symlink);
1023
1024            // And the file should have is_file = true
1025            let file_entry = entries
1026                .iter()
1027                .find(|e| e.path.file_name().unwrap().to_str().unwrap() == "file.txt");
1028            assert!(file_entry.is_some());
1029            assert!(file_entry.unwrap().metadata.is_file);
1030        }
1031    }
1032}