anyfs_backend/traits/
fs_path.rs

1//! # FsPath Trait
2//!
3//! Path canonicalization with a default implementation.
4//!
5//! ## Responsibility
6//! - Provide path canonicalization methods (resolve symlinks, normalize `.`/`..`)
7//!
8//! ## Dependencies
9//! - [`FsRead`] for checking path existence and metadata
10//! - [`FsLink`] for symlink resolution
11//! - [`FsError`] for error handling
12//!
13//! ## Usage
14//!
15//! ```rust
16//! use anyfs_backend::{FsPath, FsRead, FsLink};
17//! use std::path::Path;
18//!
19//! // Generic function that works with any FsPath implementation
20//! fn resolve<B: FsPath>(backend: &B) -> Result<(), anyfs_backend::FsError> {
21//!     // Resolve symlinks and normalize path
22//!     let path = backend.canonicalize(Path::new("/some/path/../file.txt"))?;
23//!     
24//!     // Resolve parent, allow non-existent final component
25//!     let new_path = backend.soft_canonicalize(Path::new("/dir/new_file.txt"))?;
26//!     Ok(())
27//! }
28//! ```
29
30use std::path::{Component, Path, PathBuf};
31
32use crate::{FileType, FsError, FsLink, FsRead};
33
34// ============================================================================
35// Constants
36// ============================================================================
37
38/// Maximum depth for symlink resolution to prevent infinite loops.
39const MAX_SYMLINK_DEPTH: usize = 40;
40
41// ============================================================================
42// Trait Definition
43// ============================================================================
44
45/// Path canonicalization with a default implementation.
46///
47/// This trait provides methods for resolving paths to their canonical form,
48/// handling symlinks and normalizing `.` and `..` components.
49///
50/// # Blanket Implementation
51///
52/// This trait has a blanket implementation for any type implementing
53/// [`FsRead`] + [`FsLink`], so all backends with symlink support
54/// automatically get these methods.
55///
56/// # Backend Optimization
57///
58/// Backends can override the default implementation for optimization.
59/// For example, `SqliteBackend` could use a single recursive CTE query
60/// instead of the iterative component-by-component approach.
61///
62/// # Example
63///
64/// ```rust
65/// use anyfs_backend::{FsPath, FsRead, FsLink};
66/// use std::path::Path;
67///
68/// // Generic function that works with any FsPath implementation
69/// fn resolve<B: FsPath>(backend: &B) -> Result<(), anyfs_backend::FsError> {
70///     // Resolve symlinks and normalize path
71///     let path = backend.canonicalize(Path::new("/some/path/../file.txt"))?;
72///     
73///     // Resolve parent, allow non-existent final component
74///     let new_path = backend.soft_canonicalize(Path::new("/dir/new_file.txt"))?;
75///     Ok(())
76/// }
77/// ```
78pub trait FsPath: FsRead + FsLink {
79    /// Resolve all symlinks and normalize path (`.`, `..`).
80    ///
81    /// All path components must exist. Returns error if any component
82    /// is missing or a symlink loop is detected.
83    ///
84    /// # Arguments
85    ///
86    /// * `path` - The path to canonicalize
87    ///
88    /// # Returns
89    ///
90    /// The fully resolved canonical path.
91    ///
92    /// # Errors
93    ///
94    /// - [`FsError::NotFound`] - A component doesn't exist
95    /// - [`FsError::InvalidData`] - Symlink loop detected (exceeded max depth)
96    ///
97    /// # Example
98    ///
99    /// ```rust
100    /// use anyfs_backend::FsPath;
101    /// use std::path::{Path, PathBuf};
102    ///
103    /// // Generic function that demonstrates canonicalize
104    /// fn resolve_link<B: FsPath>(backend: &B) -> Result<PathBuf, anyfs_backend::FsError> {
105    ///     // Given: /link -> /target, /target/file.txt exists
106    ///     let path = backend.canonicalize(Path::new("/link/file.txt"))?;
107    ///     // Result: PathBuf::from("/target/file.txt")
108    ///     Ok(path)
109    /// }
110    /// ```
111    fn canonicalize(&self, path: &Path) -> Result<PathBuf, FsError> {
112        default_canonicalize(self, path)
113    }
114
115    /// Like [`canonicalize`](Self::canonicalize), but allows non-existent final component.
116    ///
117    /// Resolves parent path fully, appends final component lexically.
118    /// This is useful for `write()` operations where the target file
119    /// doesn't exist yet.
120    ///
121    /// # Arguments
122    ///
123    /// * `path` - The path to soft-canonicalize
124    ///
125    /// # Returns
126    ///
127    /// The resolved path with the final component appended lexically.
128    ///
129    /// # Errors
130    ///
131    /// - [`FsError::NotFound`] - A parent component doesn't exist
132    /// - [`FsError::InvalidData`] - Symlink loop detected
133    ///
134    /// # Example
135    ///
136    /// ```rust
137    /// use anyfs_backend::FsPath;
138    /// use std::path::{Path, PathBuf};
139    ///
140    /// // Generic function that demonstrates soft_canonicalize
141    /// fn resolve_new_file<B: FsPath>(backend: &B) -> Result<PathBuf, anyfs_backend::FsError> {
142    ///     // Given: /dir exists, /dir/new_file.txt does NOT exist
143    ///     let path = backend.soft_canonicalize(Path::new("/dir/new_file.txt"))?;
144    ///     // Result: PathBuf::from("/dir/new_file.txt")
145    ///     Ok(path)
146    /// }
147    /// ```
148    fn soft_canonicalize(&self, path: &Path) -> Result<PathBuf, FsError> {
149        default_soft_canonicalize(self, path)
150    }
151}
152
153// Blanket implementation - any FsRead + FsLink gets FsPath for free
154impl<T: FsRead + FsLink> FsPath for T {}
155
156// ============================================================================
157// Default Implementations
158// ============================================================================
159
160/// Default implementation of canonicalize using iterative resolution.
161///
162/// Walks the path component by component, following symlinks and
163/// resolving `.` and `..` components.
164fn default_canonicalize<F: FsRead + FsLink + ?Sized>(
165    fs: &F,
166    path: &Path,
167) -> Result<PathBuf, FsError> {
168    resolve_path_internal(fs, path, 0, true)
169}
170
171/// Default implementation of soft_canonicalize.
172///
173/// Like canonicalize, but allows the final component to not exist.
174fn default_soft_canonicalize<F: FsRead + FsLink + ?Sized>(
175    fs: &F,
176    path: &Path,
177) -> Result<PathBuf, FsError> {
178    // Get the parent and final component
179    let parent = path.parent();
180    let file_name = path.file_name();
181
182    match (parent, file_name) {
183        (Some(parent_path), Some(name)) if !parent_path.as_os_str().is_empty() => {
184            // Resolve the parent path fully
185            let resolved_parent = resolve_path_internal(fs, parent_path, 0, true)?;
186            // Append the final component lexically
187            Ok(resolved_parent.join(name))
188        }
189        (None, Some(_)) | (Some(_), Some(_)) => {
190            // Just a filename or root + filename, return as-is normalized
191            normalize_path(path)
192        }
193        (_, None) => {
194            // No filename component (e.g., "/" or empty) - just canonicalize
195            default_canonicalize(fs, path)
196        }
197    }
198}
199
200/// Internal path resolution with symlink depth tracking.
201fn resolve_path_internal<F: FsRead + FsLink + ?Sized>(
202    fs: &F,
203    path: &Path,
204    depth: usize,
205    require_exists: bool,
206) -> Result<PathBuf, FsError> {
207    if depth > MAX_SYMLINK_DEPTH {
208        return Err(FsError::InvalidData {
209            path: path.to_path_buf(),
210            details: format!("symlink loop detected (exceeded max depth of {MAX_SYMLINK_DEPTH})"),
211        });
212    }
213
214    let mut resolved = PathBuf::new();
215
216    for component in path.components() {
217        match component {
218            Component::RootDir => {
219                resolved = PathBuf::from("/");
220            }
221            Component::CurDir => {
222                // `.` - skip, don't change resolved path
223            }
224            Component::ParentDir => {
225                // `..` - go up one level
226                resolved.pop();
227                // Ensure we don't go above root
228                if resolved.as_os_str().is_empty() {
229                    resolved = PathBuf::from("/");
230                }
231            }
232            Component::Normal(name) => {
233                resolved.push(name);
234
235                // Check if this component is a symlink
236                match fs.symlink_metadata(&resolved) {
237                    Ok(meta) => {
238                        if meta.file_type == FileType::Symlink {
239                            // Read the symlink target
240                            let target = fs.read_link(&resolved)?;
241
242                            // Remove the symlink from resolved path
243                            resolved.pop();
244
245                            // Resolve the target path
246                            let target_resolved = if target.is_absolute() {
247                                resolve_path_internal(fs, &target, depth + 1, require_exists)?
248                            } else {
249                                // Relative symlink - resolve relative to current resolved path
250                                let full_target = resolved.join(&target);
251                                resolve_path_internal(fs, &full_target, depth + 1, require_exists)?
252                            };
253
254                            resolved = target_resolved;
255                        }
256                        // If not a symlink, keep it in resolved path
257                    }
258                    Err(FsError::NotFound { .. }) if !require_exists => {
259                        // Component doesn't exist but we're in soft mode
260                        // Keep it in the path
261                    }
262                    Err(e) => return Err(e),
263                }
264            }
265            Component::Prefix(_) => {
266                // Windows prefix handling - for cross-platform support
267                // Virtual backends use Unix-style paths internally
268                resolved.push(component);
269            }
270        }
271    }
272
273    // Ensure we have at least root
274    if resolved.as_os_str().is_empty() {
275        resolved = PathBuf::from("/");
276    }
277
278    // Final existence check for canonicalize (not soft)
279    if require_exists && !fs.exists(&resolved)? {
280        return Err(FsError::NotFound { path: resolved });
281    }
282
283    Ok(resolved)
284}
285
286/// Simple lexical path normalization without filesystem access.
287///
288/// Handles `.`, `..`, and multiple slashes but does NOT follow symlinks.
289fn normalize_path(path: &Path) -> Result<PathBuf, FsError> {
290    let mut normalized = PathBuf::new();
291
292    for component in path.components() {
293        match component {
294            Component::RootDir => {
295                normalized = PathBuf::from("/");
296            }
297            Component::CurDir => {
298                // Skip `.`
299            }
300            Component::ParentDir => {
301                normalized.pop();
302                if normalized.as_os_str().is_empty() {
303                    normalized = PathBuf::from("/");
304                }
305            }
306            Component::Normal(name) => {
307                normalized.push(name);
308            }
309            Component::Prefix(prefix) => {
310                normalized.push(prefix.as_os_str());
311            }
312        }
313    }
314
315    if normalized.as_os_str().is_empty() {
316        normalized = PathBuf::from("/");
317    }
318
319    Ok(normalized)
320}
321
322// ============================================================================
323// Tests
324// ============================================================================
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::{FsDir, FsWrite, Metadata, Permissions, ReadDirIter};
330    use std::collections::HashMap;
331    use std::io::{Read, Write};
332    use std::sync::RwLock;
333    use std::time::SystemTime;
334
335    // Mock filesystem with configurable entries
336    struct MockFs {
337        entries: RwLock<HashMap<PathBuf, MockEntry>>,
338    }
339
340    #[derive(Clone)]
341    enum MockEntry {
342        File,
343        Directory,
344        Symlink(PathBuf),
345    }
346
347    impl MockFs {
348        fn new() -> Self {
349            let mut entries = HashMap::new();
350            // Root always exists
351            entries.insert(PathBuf::from("/"), MockEntry::Directory);
352            Self {
353                entries: RwLock::new(entries),
354            }
355        }
356
357        fn add_file(&self, path: impl Into<PathBuf>) {
358            self.entries
359                .write()
360                .unwrap()
361                .insert(path.into(), MockEntry::File);
362        }
363
364        fn add_dir(&self, path: impl Into<PathBuf>) {
365            self.entries
366                .write()
367                .unwrap()
368                .insert(path.into(), MockEntry::Directory);
369        }
370
371        fn add_symlink(&self, path: impl Into<PathBuf>, target: impl Into<PathBuf>) {
372            self.entries
373                .write()
374                .unwrap()
375                .insert(path.into(), MockEntry::Symlink(target.into()));
376        }
377    }
378
379    impl FsRead for MockFs {
380        fn read(&self, _path: &Path) -> Result<Vec<u8>, FsError> {
381            Ok(vec![])
382        }
383
384        fn read_to_string(&self, _path: &Path) -> Result<String, FsError> {
385            Ok(String::new())
386        }
387
388        fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> Result<Vec<u8>, FsError> {
389            Ok(vec![])
390        }
391
392        fn exists(&self, path: &Path) -> Result<bool, FsError> {
393            Ok(self.entries.read().unwrap().contains_key(path))
394        }
395
396        fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
397            let entries = self.entries.read().unwrap();
398            match entries.get(path) {
399                Some(entry) => {
400                    let file_type = match entry {
401                        MockEntry::File => FileType::File,
402                        MockEntry::Directory => FileType::Directory,
403                        MockEntry::Symlink(target) => {
404                            // Follow symlink for metadata - clone target first
405                            let target = target.clone();
406                            drop(entries);
407                            return self.metadata(&target);
408                        }
409                    };
410                    Ok(Metadata {
411                        file_type,
412                        size: 0,
413                        permissions: Permissions::default_file(),
414                        created: SystemTime::UNIX_EPOCH,
415                        modified: SystemTime::UNIX_EPOCH,
416                        accessed: SystemTime::UNIX_EPOCH,
417                        inode: 1,
418                        nlink: 1,
419                    })
420                }
421                None => Err(FsError::NotFound {
422                    path: path.to_path_buf(),
423                }),
424            }
425        }
426
427        fn open_read(&self, _path: &Path) -> Result<Box<dyn Read + Send>, FsError> {
428            Ok(Box::new(std::io::empty()))
429        }
430    }
431
432    impl FsWrite for MockFs {
433        fn write(&self, _path: &Path, _data: &[u8]) -> Result<(), FsError> {
434            Ok(())
435        }
436
437        fn append(&self, _path: &Path, _data: &[u8]) -> Result<(), FsError> {
438            Ok(())
439        }
440
441        fn remove_file(&self, _path: &Path) -> Result<(), FsError> {
442            Ok(())
443        }
444
445        fn rename(&self, _from: &Path, _to: &Path) -> Result<(), FsError> {
446            Ok(())
447        }
448
449        fn copy(&self, _from: &Path, _to: &Path) -> Result<(), FsError> {
450            Ok(())
451        }
452
453        fn truncate(&self, _path: &Path, _size: u64) -> Result<(), FsError> {
454            Ok(())
455        }
456
457        fn open_write(&self, _path: &Path) -> Result<Box<dyn Write + Send>, FsError> {
458            Ok(Box::new(std::io::sink()))
459        }
460    }
461
462    impl FsDir for MockFs {
463        fn read_dir(&self, _path: &Path) -> Result<ReadDirIter, FsError> {
464            Ok(ReadDirIter::from_vec(vec![]))
465        }
466
467        fn create_dir(&self, _path: &Path) -> Result<(), FsError> {
468            Ok(())
469        }
470
471        fn create_dir_all(&self, _path: &Path) -> Result<(), FsError> {
472            Ok(())
473        }
474
475        fn remove_dir(&self, _path: &Path) -> Result<(), FsError> {
476            Ok(())
477        }
478
479        fn remove_dir_all(&self, _path: &Path) -> Result<(), FsError> {
480            Ok(())
481        }
482    }
483
484    impl FsLink for MockFs {
485        fn symlink(&self, _target: &Path, _link: &Path) -> Result<(), FsError> {
486            Ok(())
487        }
488
489        fn hard_link(&self, _original: &Path, _link: &Path) -> Result<(), FsError> {
490            Ok(())
491        }
492
493        fn read_link(&self, path: &Path) -> Result<PathBuf, FsError> {
494            let entries = self.entries.read().unwrap();
495            match entries.get(path) {
496                Some(MockEntry::Symlink(target)) => Ok(target.clone()),
497                Some(_) => Err(FsError::InvalidData {
498                    path: path.to_path_buf(),
499                    details: "not a symlink".to_string(),
500                }),
501                None => Err(FsError::NotFound {
502                    path: path.to_path_buf(),
503                }),
504            }
505        }
506
507        fn symlink_metadata(&self, path: &Path) -> Result<Metadata, FsError> {
508            let entries = self.entries.read().unwrap();
509            match entries.get(path) {
510                Some(entry) => {
511                    let file_type = match entry {
512                        MockEntry::File => FileType::File,
513                        MockEntry::Directory => FileType::Directory,
514                        MockEntry::Symlink(_) => FileType::Symlink,
515                    };
516                    Ok(Metadata {
517                        file_type,
518                        size: 0,
519                        permissions: Permissions::default_file(),
520                        created: SystemTime::UNIX_EPOCH,
521                        modified: SystemTime::UNIX_EPOCH,
522                        accessed: SystemTime::UNIX_EPOCH,
523                        inode: 1,
524                        nlink: 1,
525                    })
526                }
527                None => Err(FsError::NotFound {
528                    path: path.to_path_buf(),
529                }),
530            }
531        }
532    }
533
534    #[test]
535    fn fs_path_blanket_impl_works() {
536        // Verify the blanket impl works
537        let fs = MockFs::new();
538        fs.add_dir(PathBuf::from("/test"));
539        fs.add_file(PathBuf::from("/test/file.txt"));
540
541        // Should be able to call FsPath methods on MockFs
542        let result = fs.canonicalize(Path::new("/test/file.txt"));
543        assert!(result.is_ok());
544    }
545
546    #[test]
547    fn canonicalize_simple_path() {
548        let fs = MockFs::new();
549        fs.add_dir(PathBuf::from("/dir"));
550        fs.add_file(PathBuf::from("/dir/file.txt"));
551
552        let result = fs.canonicalize(Path::new("/dir/file.txt"));
553        assert_eq!(result.unwrap(), PathBuf::from("/dir/file.txt"));
554    }
555
556    #[test]
557    fn canonicalize_resolves_dot() {
558        let fs = MockFs::new();
559        fs.add_dir(PathBuf::from("/dir"));
560        fs.add_file(PathBuf::from("/dir/file.txt"));
561
562        let result = fs.canonicalize(Path::new("/dir/./file.txt"));
563        assert_eq!(result.unwrap(), PathBuf::from("/dir/file.txt"));
564    }
565
566    #[test]
567    fn canonicalize_resolves_dotdot() {
568        let fs = MockFs::new();
569        fs.add_dir(PathBuf::from("/dir"));
570        fs.add_dir(PathBuf::from("/dir/sub"));
571        fs.add_file(PathBuf::from("/dir/file.txt"));
572
573        let result = fs.canonicalize(Path::new("/dir/sub/../file.txt"));
574        assert_eq!(result.unwrap(), PathBuf::from("/dir/file.txt"));
575    }
576
577    #[test]
578    fn canonicalize_follows_symlink() {
579        let fs = MockFs::new();
580        fs.add_dir(PathBuf::from("/target"));
581        fs.add_file(PathBuf::from("/target/file.txt"));
582        fs.add_symlink(PathBuf::from("/link"), PathBuf::from("/target"));
583
584        let result = fs.canonicalize(Path::new("/link/file.txt"));
585        assert_eq!(result.unwrap(), PathBuf::from("/target/file.txt"));
586    }
587
588    #[test]
589    fn canonicalize_follows_relative_symlink() {
590        let fs = MockFs::new();
591        fs.add_dir(PathBuf::from("/dir"));
592        fs.add_dir(PathBuf::from("/dir/target"));
593        fs.add_file(PathBuf::from("/dir/target/file.txt"));
594        fs.add_symlink(PathBuf::from("/dir/link"), PathBuf::from("target"));
595
596        let result = fs.canonicalize(Path::new("/dir/link/file.txt"));
597        assert_eq!(result.unwrap(), PathBuf::from("/dir/target/file.txt"));
598    }
599
600    #[test]
601    fn canonicalize_detects_symlink_loop() {
602        let fs = MockFs::new();
603        fs.add_symlink(PathBuf::from("/loop1"), PathBuf::from("/loop2"));
604        fs.add_symlink(PathBuf::from("/loop2"), PathBuf::from("/loop1"));
605
606        let result = fs.canonicalize(Path::new("/loop1"));
607        assert!(result.is_err());
608        if let Err(FsError::InvalidData { details, .. }) = result {
609            assert!(details.contains("symlink loop"));
610        } else {
611            panic!("Expected InvalidData error for symlink loop");
612        }
613    }
614
615    #[test]
616    fn canonicalize_not_found() {
617        let fs = MockFs::new();
618
619        let result = fs.canonicalize(Path::new("/nonexistent"));
620        assert!(matches!(result, Err(FsError::NotFound { .. })));
621    }
622
623    #[test]
624    fn soft_canonicalize_allows_nonexistent_final() {
625        let fs = MockFs::new();
626        fs.add_dir(PathBuf::from("/dir"));
627
628        // /dir exists, but /dir/new_file.txt does not
629        let result = fs.soft_canonicalize(Path::new("/dir/new_file.txt"));
630        assert_eq!(result.unwrap(), PathBuf::from("/dir/new_file.txt"));
631    }
632
633    #[test]
634    fn soft_canonicalize_resolves_parent_symlink() {
635        let fs = MockFs::new();
636        fs.add_dir(PathBuf::from("/target"));
637        fs.add_symlink(PathBuf::from("/link"), PathBuf::from("/target"));
638
639        // /link -> /target, so /link/new.txt -> /target/new.txt
640        let result = fs.soft_canonicalize(Path::new("/link/new.txt"));
641        assert_eq!(result.unwrap(), PathBuf::from("/target/new.txt"));
642    }
643
644    #[test]
645    fn soft_canonicalize_fails_for_nonexistent_parent() {
646        let fs = MockFs::new();
647        // /nonexistent doesn't exist
648
649        let result = fs.soft_canonicalize(Path::new("/nonexistent/file.txt"));
650        assert!(matches!(result, Err(FsError::NotFound { .. })));
651    }
652
653    #[test]
654    fn canonicalize_root() {
655        let fs = MockFs::new();
656
657        let result = fs.canonicalize(Path::new("/"));
658        assert_eq!(result.unwrap(), PathBuf::from("/"));
659    }
660
661    #[test]
662    fn normalize_path_handles_dots() {
663        let result = normalize_path(Path::new("/a/./b/../c"));
664        assert_eq!(result.unwrap(), PathBuf::from("/a/c"));
665    }
666
667    #[test]
668    fn normalize_path_handles_root() {
669        let result = normalize_path(Path::new("/"));
670        assert_eq!(result.unwrap(), PathBuf::from("/"));
671    }
672}