Skip to main content

arcbox_fs/
passthrough.rs

1//! Passthrough filesystem implementation.
2//!
3//! Maps guest filesystem operations directly to host filesystem.
4//! This is the core filesystem backend that provides actual file I/O.
5
6// Allow casts that are necessary for system calls (libc interop)
7#![allow(
8    clippy::cast_sign_loss,
9    clippy::cast_possible_wrap,
10    clippy::cast_possible_truncation
11)]
12
13use crate::cache::{NegativeCache, NegativeCacheConfig};
14use crate::error::{FsError, Result};
15use std::collections::HashMap;
16use std::ffi::{OsStr, OsString};
17use std::fs::{File, OpenOptions};
18use std::io::{Read, Seek, SeekFrom, Write};
19use std::os::unix::ffi::OsStrExt;
20use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
21use std::path::{Path, PathBuf};
22use std::sync::RwLock;
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::time::Duration;
25
26// ============================================================================
27// Inode Management
28// ============================================================================
29
30/// Inode data stored for each known file/directory.
31#[derive(Debug)]
32#[allow(dead_code)]
33struct InodeData {
34    /// Path relative to root.
35    path: PathBuf,
36    /// Reference count (for FUSE forget).
37    refcount: AtomicU64,
38    /// File type from stat mode.
39    file_type: FileType,
40    /// Host kernel inode number (`st_ino`) at registration time.
41    ///
42    /// Used by `DaxFsExt::open_inode_for_dax` for TOCTOU detection: after
43    /// opening the file by path we compare the opened fd's `st_ino` against
44    /// this value. A mismatch means the directory entry was swapped (renamed)
45    /// between our path-to-inode resolution and the open call.
46    kernel_ino: u64,
47}
48
49impl InodeData {
50    fn new(path: PathBuf, file_type: FileType, kernel_ino: u64) -> Self {
51        Self {
52            path,
53            refcount: AtomicU64::new(1),
54            file_type,
55            kernel_ino,
56        }
57    }
58
59    fn inc_ref(&self) {
60        self.refcount.fetch_add(1, Ordering::Relaxed);
61    }
62
63    fn dec_ref(&self) -> u64 {
64        self.refcount.fetch_sub(1, Ordering::Relaxed)
65    }
66}
67
68/// File type enumeration.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum FileType {
71    Regular,
72    Directory,
73    Symlink,
74    BlockDevice,
75    CharDevice,
76    Fifo,
77    Socket,
78    Unknown,
79}
80
81impl FileType {
82    fn from_mode(mode: u32) -> Self {
83        let file_type = mode & u32::from(libc::S_IFMT);
84        if file_type == u32::from(libc::S_IFREG) {
85            Self::Regular
86        } else if file_type == u32::from(libc::S_IFDIR) {
87            Self::Directory
88        } else if file_type == u32::from(libc::S_IFLNK) {
89            Self::Symlink
90        } else if file_type == u32::from(libc::S_IFBLK) {
91            Self::BlockDevice
92        } else if file_type == u32::from(libc::S_IFCHR) {
93            Self::CharDevice
94        } else if file_type == u32::from(libc::S_IFIFO) {
95            Self::Fifo
96        } else if file_type == u32::from(libc::S_IFSOCK) {
97            Self::Socket
98        } else {
99            Self::Unknown
100        }
101    }
102
103    #[allow(dead_code)]
104    fn is_dir(self) -> bool {
105        self == Self::Directory
106    }
107
108    /// Converts to dirent type (DT_*).
109    #[must_use]
110    pub fn to_dirent_type(self) -> u32 {
111        match self {
112            Self::Regular => libc::DT_REG as u32,
113            Self::Directory => libc::DT_DIR as u32,
114            Self::Symlink => libc::DT_LNK as u32,
115            Self::BlockDevice => libc::DT_BLK as u32,
116            Self::CharDevice => libc::DT_CHR as u32,
117            Self::Fifo => libc::DT_FIFO as u32,
118            Self::Socket => libc::DT_SOCK as u32,
119            Self::Unknown => libc::DT_UNKNOWN as u32,
120        }
121    }
122}
123
124// ============================================================================
125// File Handle Management
126// ============================================================================
127
128/// Handle data for an open file.
129#[derive(Debug)]
130#[allow(dead_code)]
131struct HandleData {
132    /// The open file.
133    file: File,
134    /// Inode this handle refers to.
135    inode: u64,
136    /// Open flags.
137    flags: u32,
138}
139
140/// Handle data for an open directory.
141#[derive(Debug)]
142struct DirHandleData {
143    /// Inode this handle refers to.
144    inode: u64,
145    /// Cached directory entries.
146    entries: Vec<DirEntry>,
147}
148
149/// A directory entry.
150#[derive(Debug, Clone)]
151pub struct DirEntry {
152    /// Entry name.
153    pub name: OsString,
154    /// Inode number.
155    pub ino: u64,
156    /// File type.
157    pub file_type: FileType,
158}
159
160// ============================================================================
161// Configuration
162// ============================================================================
163
164/// Configuration for the passthrough filesystem.
165#[derive(Debug, Clone)]
166pub struct PassthroughConfig {
167    /// Enable negative cache for non-existent file lookups.
168    pub negative_cache_enabled: bool,
169    /// Maximum entries in the negative cache.
170    pub negative_cache_max_entries: usize,
171    /// Timeout for negative cache entries.
172    pub negative_cache_timeout: Duration,
173}
174
175impl Default for PassthroughConfig {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl PassthroughConfig {
182    /// Creates a new configuration with default values.
183    #[must_use]
184    pub const fn new() -> Self {
185        Self {
186            negative_cache_enabled: true,
187            negative_cache_max_entries: 10_000,
188            negative_cache_timeout: Duration::from_secs(5),
189        }
190    }
191}
192
193// ============================================================================
194// Passthrough Filesystem
195// ============================================================================
196
197/// Passthrough filesystem.
198///
199/// Implements a passthrough filesystem that maps all operations
200/// to the underlying host filesystem. Includes negative caching
201/// to optimize lookups for non-existent files.
202pub struct PassthroughFs {
203    /// Root directory path on host.
204    root: PathBuf,
205    /// Inode to data mapping.
206    inodes: RwLock<HashMap<u64, InodeData>>,
207    /// Next inode number.
208    next_inode: AtomicU64,
209    /// File handle to data mapping.
210    handles: RwLock<HashMap<u64, HandleData>>,
211    /// Directory handle to data mapping.
212    dir_handles: RwLock<HashMap<u64, DirHandleData>>,
213    /// Next handle number.
214    next_handle: AtomicU64,
215    /// Negative cache for non-existent paths.
216    negative_cache: Option<NegativeCache>,
217    /// Configuration.
218    #[allow(dead_code)]
219    config: PassthroughConfig,
220}
221
222impl PassthroughFs {
223    /// Root inode number.
224    pub const ROOT_INODE: u64 = 1;
225
226    /// Creates a new passthrough filesystem with default configuration.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`FsError::InvalidPath`] if the root path is not a directory.
231    pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
232        Self::with_config(root, PassthroughConfig::default())
233    }
234
235    /// Creates a new passthrough filesystem with custom configuration.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`FsError::InvalidPath`] if the root path is not a directory.
240    pub fn with_config(root: impl Into<PathBuf>, config: PassthroughConfig) -> Result<Self> {
241        let root = root.into();
242        if !root.is_dir() {
243            return Err(FsError::InvalidPath(format!(
244                "root path is not a directory: {}",
245                root.display()
246            )));
247        }
248
249        let negative_cache = if config.negative_cache_enabled {
250            Some(NegativeCache::new(NegativeCacheConfig {
251                max_entries: config.negative_cache_max_entries,
252                timeout: config.negative_cache_timeout,
253                adaptive_ttl: Some(crate::cache::AdaptiveTtlConfig::default()),
254            }))
255        } else {
256            None
257        };
258
259        // Initialize root inode — stat the root directory to capture its
260        // kernel st_ino at registration time for TOCTOU detection.
261        // Propagating the error here rather than silently falling back to 0:
262        // a kernel_ino of 0 would cause every subsequent DAX TOCTOU check on
263        // the root inode to silently pass (any real st_ino is non-zero), turning
264        // the guard into a no-op and masking rename-based attacks on the root.
265        let root_kernel_ino = {
266            use std::os::unix::fs::MetadataExt;
267            std::fs::symlink_metadata(&root)
268                .map(|m| m.ino())
269                .map_err(|e| {
270                    FsError::io(std::io::Error::new(
271                        e.kind(),
272                        format!("failed to stat root path '{}': {e}", root.display()),
273                    ))
274                })?
275        };
276        let mut inodes = HashMap::new();
277        inodes.insert(
278            Self::ROOT_INODE,
279            InodeData::new(PathBuf::new(), FileType::Directory, root_kernel_ino),
280        );
281
282        Ok(Self {
283            root,
284            inodes: RwLock::new(inodes),
285            next_inode: AtomicU64::new(Self::ROOT_INODE + 1),
286            handles: RwLock::new(HashMap::new()),
287            dir_handles: RwLock::new(HashMap::new()),
288            next_handle: AtomicU64::new(1),
289            negative_cache,
290            config,
291        })
292    }
293
294    /// Returns the root directory path.
295    #[must_use]
296    pub fn root(&self) -> &Path {
297        &self.root
298    }
299
300    /// Returns a reference to the negative cache, if enabled.
301    #[must_use]
302    pub fn negative_cache(&self) -> Option<&NegativeCache> {
303        self.negative_cache.as_ref()
304    }
305
306    // ========================================================================
307    // Internal Helpers
308    // ========================================================================
309
310    /// Allocates a new inode number.
311    fn alloc_inode(&self) -> u64 {
312        self.next_inode.fetch_add(1, Ordering::Relaxed)
313    }
314
315    /// Allocates a new handle number.
316    fn alloc_handle(&self) -> u64 {
317        self.next_handle.fetch_add(1, Ordering::Relaxed)
318    }
319
320    /// Gets the full host path for an inode.
321    pub(crate) fn inode_path(&self, inode: u64) -> Result<PathBuf> {
322        if inode == Self::ROOT_INODE {
323            return Ok(self.root.clone());
324        }
325
326        let inodes = self
327            .inodes
328            .read()
329            .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
330
331        let data = inodes.get(&inode).ok_or(FsError::InvalidHandle(inode))?;
332        Ok(self.root.join(&data.path))
333    }
334
335    /// Returns the host kernel `st_ino` that was recorded when this inode was
336    /// first registered (at `lookup` / `create` / `mkdir` / `mknod` time).
337    ///
338    /// Used by `DaxFsExt::open_inode_for_dax` to detect TOCTOU swaps: if the
339    /// opened fd's `st_ino` differs from this value the directory entry was
340    /// renamed between resolution and open.
341    pub(crate) fn kernel_ino_for(&self, inode: u64) -> Option<u64> {
342        let inodes = self
343            .inodes
344            .read()
345            .unwrap_or_else(std::sync::PoisonError::into_inner);
346        inodes.get(&inode).map(|d| d.kernel_ino)
347    }
348
349    /// Constructs the full host path for a given parent inode and name.
350    #[allow(clippy::significant_drop_tightening)]
351    fn get_path(&self, parent: u64, name: &OsStr) -> Result<PathBuf> {
352        if parent == Self::ROOT_INODE {
353            return Ok(self.root.join(name));
354        }
355
356        let inodes = self
357            .inodes
358            .read()
359            .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
360
361        let parent_data = inodes.get(&parent).ok_or(FsError::InvalidHandle(parent))?;
362        Ok(self.root.join(&parent_data.path).join(name))
363    }
364
365    /// Gets the relative path from full path.
366    fn relative_path(&self, path: &Path) -> PathBuf {
367        path.strip_prefix(&self.root)
368            .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
369    }
370
371    /// Creates `FuseAttr` from metadata.
372    #[allow(clippy::cast_possible_truncation)]
373    fn metadata_to_attr(ino: u64, metadata: &std::fs::Metadata) -> crate::fuse::FuseAttr {
374        crate::fuse::FuseAttr {
375            ino,
376            size: metadata.len(),
377            blocks: metadata.blocks(),
378            atime: metadata.atime() as u64,
379            mtime: metadata.mtime() as u64,
380            ctime: metadata.ctime() as u64,
381            atimensec: metadata.atime_nsec() as u32,
382            mtimensec: metadata.mtime_nsec() as u32,
383            ctimensec: metadata.ctime_nsec() as u32,
384            mode: metadata.mode(),
385            nlink: metadata.nlink() as u32,
386            uid: metadata.uid(),
387            gid: metadata.gid(),
388            rdev: metadata.rdev() as u32,
389            blksize: metadata.blksize() as u32,
390            padding: 0,
391        }
392    }
393
394    /// Invalidates negative cache for a path.
395    fn invalidate_negative_cache(&self, path: &Path) {
396        if let Some(ref cache) = self.negative_cache {
397            tracing::trace!(path = %path.display(), "invalidating negative cache");
398            cache.invalidate(path);
399        }
400    }
401
402    // ========================================================================
403    // Inode Operations
404    // ========================================================================
405
406    /// Looks up a name in a directory.
407    ///
408    /// # Errors
409    ///
410    /// - [`FsError::NotFound`] if the file doesn't exist
411    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
412    /// - [`FsError::Io`] for other I/O errors
413    pub fn lookup(&self, parent: u64, name: &OsStr) -> Result<(u64, crate::fuse::FuseAttr)> {
414        let path = self.get_path(parent, name)?;
415
416        // Fast path: check negative cache first
417        if let Some(ref cache) = self.negative_cache {
418            if cache.contains(&path) {
419                tracing::trace!(path = %path.display(), "negative cache hit");
420                return Err(FsError::not_found(path.display().to_string()));
421            }
422        }
423
424        // Perform actual lookup
425        let metadata = match std::fs::symlink_metadata(&path) {
426            Ok(m) => m,
427            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
428                // Add to negative cache
429                if let Some(ref cache) = self.negative_cache {
430                    tracing::trace!(path = %path.display(), "adding to negative cache");
431                    cache.insert(path.clone());
432                }
433                return Err(FsError::not_found(path.display().to_string()));
434            }
435            Err(e) => return Err(FsError::io(e)),
436        };
437
438        let file_type = FileType::from_mode(metadata.mode());
439        let relative = self.relative_path(&path);
440
441        // Check if we already have this inode
442        {
443            let inodes = self
444                .inodes
445                .read()
446                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
447            for (&ino, data) in inodes.iter() {
448                if data.path == relative {
449                    data.inc_ref();
450                    return Ok((ino, Self::metadata_to_attr(ino, &metadata)));
451                }
452            }
453        }
454
455        // Create new inode
456        let kernel_ino = metadata.ino();
457        let inode = self.alloc_inode();
458        {
459            let mut inodes = self
460                .inodes
461                .write()
462                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
463            inodes.insert(inode, InodeData::new(relative, file_type, kernel_ino));
464        }
465
466        Ok((inode, Self::metadata_to_attr(inode, &metadata)))
467    }
468
469    /// Decrements the reference count of an inode.
470    ///
471    /// When the count reaches zero, the inode is removed from the table.
472    pub fn forget(&self, inode: u64, nlookup: u64) {
473        if inode == Self::ROOT_INODE {
474            return;
475        }
476
477        let should_remove = {
478            let inodes = match self.inodes.read() {
479                Ok(i) => i,
480                Err(_) => return,
481            };
482            if let Some(data) = inodes.get(&inode) {
483                for _ in 0..nlookup {
484                    if data.dec_ref() == 1 {
485                        return;
486                    }
487                }
488                data.refcount.load(Ordering::Relaxed) == 0
489            } else {
490                false
491            }
492        };
493
494        if should_remove {
495            if let Ok(mut inodes) = self.inodes.write() {
496                inodes.remove(&inode);
497            }
498        }
499    }
500
501    /// Gets file attributes.
502    ///
503    /// # Errors
504    ///
505    /// - [`FsError::Io`] if the attributes cannot be retrieved
506    /// - [`FsError::InvalidHandle`] if the inode is invalid
507    pub fn getattr(&self, inode: u64) -> Result<crate::fuse::FuseAttr> {
508        let path = self.inode_path(inode)?;
509        let metadata = std::fs::symlink_metadata(&path).map_err(FsError::io)?;
510        Ok(Self::metadata_to_attr(inode, &metadata))
511    }
512
513    /// Sets file attributes.
514    ///
515    /// # Errors
516    ///
517    /// - [`FsError::Io`] if the attributes cannot be set
518    /// - [`FsError::InvalidHandle`] if the inode is invalid
519    #[allow(clippy::too_many_arguments)]
520    pub fn setattr(
521        &self,
522        inode: u64,
523        mode: Option<u32>,
524        uid: Option<u32>,
525        gid: Option<u32>,
526        size: Option<u64>,
527        atime: Option<(i64, u32)>,
528        mtime: Option<(i64, u32)>,
529    ) -> Result<crate::fuse::FuseAttr> {
530        let path = self.inode_path(inode)?;
531
532        // Set mode
533        if let Some(mode) = mode {
534            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))
535                .map_err(FsError::io)?;
536        }
537
538        // Set owner
539        if uid.is_some() || gid.is_some() {
540            let uid = uid.map_or(-1_i32 as libc::uid_t, |u| u);
541            let gid = gid.map_or(-1_i32 as libc::gid_t, |g| g);
542            let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
543                .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
544            let ret = unsafe { libc::chown(path_cstr.as_ptr(), uid, gid) };
545            if ret != 0 {
546                return Err(FsError::io(std::io::Error::last_os_error()));
547            }
548        }
549
550        // Set size (truncate)
551        if let Some(size) = size {
552            let file = OpenOptions::new()
553                .write(true)
554                .open(&path)
555                .map_err(FsError::io)?;
556            file.set_len(size).map_err(FsError::io)?;
557        }
558
559        // Set times
560        if atime.is_some() || mtime.is_some() {
561            let atime_spec = atime.map_or(
562                libc::timespec {
563                    tv_sec: 0,
564                    tv_nsec: libc::UTIME_OMIT,
565                },
566                |(sec, nsec)| libc::timespec {
567                    tv_sec: sec,
568                    tv_nsec: i64::from(nsec),
569                },
570            );
571            let mtime_spec = mtime.map_or(
572                libc::timespec {
573                    tv_sec: 0,
574                    tv_nsec: libc::UTIME_OMIT,
575                },
576                |(sec, nsec)| libc::timespec {
577                    tv_sec: sec,
578                    tv_nsec: i64::from(nsec),
579                },
580            );
581            let times = [atime_spec, mtime_spec];
582            let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
583                .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
584            let ret =
585                unsafe { libc::utimensat(libc::AT_FDCWD, path_cstr.as_ptr(), times.as_ptr(), 0) };
586            if ret != 0 {
587                return Err(FsError::io(std::io::Error::last_os_error()));
588            }
589        }
590
591        self.getattr(inode)
592    }
593
594    /// Reads a symbolic link.
595    ///
596    /// # Errors
597    ///
598    /// - [`FsError::Io`] if the link cannot be read
599    /// - [`FsError::InvalidHandle`] if the inode is invalid
600    pub fn readlink(&self, inode: u64) -> Result<PathBuf> {
601        let path = self.inode_path(inode)?;
602        std::fs::read_link(&path).map_err(FsError::io)
603    }
604
605    // ========================================================================
606    // File Creation Operations
607    // ========================================================================
608
609    /// Creates a file.
610    ///
611    /// # Errors
612    ///
613    /// - [`FsError::Io`] if the file cannot be created
614    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
615    pub fn create(
616        &self,
617        parent: u64,
618        name: &OsStr,
619        mode: u32,
620        flags: u32,
621    ) -> Result<(u64, crate::fuse::FuseAttr, u64)> {
622        let path = self.get_path(parent, name)?;
623
624        // Build open options from flags
625        let mut opts = OpenOptions::new();
626        Self::apply_flags(&mut opts, flags);
627        opts.create(true);
628        opts.mode(mode & 0o7777);
629
630        let file = opts.open(&path).map_err(FsError::io)?;
631        let metadata = file.metadata().map_err(FsError::io)?;
632
633        // Invalidate negative cache
634        self.invalidate_negative_cache(&path);
635
636        // Create inode
637        let relative = self.relative_path(&path);
638        let kernel_ino = metadata.ino();
639        let inode = self.alloc_inode();
640        {
641            let mut inodes = self
642                .inodes
643                .write()
644                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
645            inodes.insert(
646                inode,
647                InodeData::new(relative, FileType::Regular, kernel_ino),
648            );
649        }
650
651        // Create file handle
652        let handle = self.alloc_handle();
653        {
654            let mut handles = self
655                .handles
656                .write()
657                .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
658            handles.insert(handle, HandleData { file, inode, flags });
659        }
660
661        Ok((inode, Self::metadata_to_attr(inode, &metadata), handle))
662    }
663
664    /// Creates a directory.
665    ///
666    /// # Errors
667    ///
668    /// - [`FsError::Io`] if the directory cannot be created
669    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
670    pub fn mkdir(
671        &self,
672        parent: u64,
673        name: &OsStr,
674        mode: u32,
675    ) -> Result<(u64, crate::fuse::FuseAttr)> {
676        let path = self.get_path(parent, name)?;
677
678        std::fs::create_dir(&path).map_err(FsError::io)?;
679        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode & 0o7777))
680            .map_err(FsError::io)?;
681
682        self.invalidate_negative_cache(&path);
683
684        let metadata = std::fs::symlink_metadata(&path).map_err(FsError::io)?;
685        let relative = self.relative_path(&path);
686        let kernel_ino = metadata.ino();
687        let inode = self.alloc_inode();
688
689        {
690            let mut inodes = self
691                .inodes
692                .write()
693                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
694            inodes.insert(
695                inode,
696                InodeData::new(relative, FileType::Directory, kernel_ino),
697            );
698        }
699
700        Ok((inode, Self::metadata_to_attr(inode, &metadata)))
701    }
702
703    /// Creates a symbolic link.
704    ///
705    /// # Errors
706    ///
707    /// - [`FsError::Io`] if the symlink cannot be created
708    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
709    pub fn symlink(
710        &self,
711        parent: u64,
712        name: &OsStr,
713        target: &Path,
714    ) -> Result<(u64, crate::fuse::FuseAttr)> {
715        let path = self.get_path(parent, name)?;
716
717        std::os::unix::fs::symlink(target, &path).map_err(FsError::io)?;
718
719        self.invalidate_negative_cache(&path);
720
721        let metadata = std::fs::symlink_metadata(&path).map_err(FsError::io)?;
722        let relative = self.relative_path(&path);
723        let kernel_ino = metadata.ino();
724        let inode = self.alloc_inode();
725
726        {
727            let mut inodes = self
728                .inodes
729                .write()
730                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
731            inodes.insert(
732                inode,
733                InodeData::new(relative, FileType::Symlink, kernel_ino),
734            );
735        }
736
737        Ok((inode, Self::metadata_to_attr(inode, &metadata)))
738    }
739
740    /// Creates a hard link.
741    ///
742    /// # Errors
743    ///
744    /// - [`FsError::Io`] if the link cannot be created
745    /// - [`FsError::InvalidHandle`] if the source or parent inode is invalid
746    pub fn link(
747        &self,
748        inode: u64,
749        new_parent: u64,
750        new_name: &OsStr,
751    ) -> Result<(u64, crate::fuse::FuseAttr)> {
752        let source_path = self.inode_path(inode)?;
753        let new_path = self.get_path(new_parent, new_name)?;
754
755        std::fs::hard_link(&source_path, &new_path).map_err(FsError::io)?;
756
757        self.invalidate_negative_cache(&new_path);
758
759        // Hard link shares the same inode
760        let metadata = std::fs::symlink_metadata(&new_path).map_err(FsError::io)?;
761
762        // Increment reference count
763        {
764            let inodes = self
765                .inodes
766                .read()
767                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
768            if let Some(data) = inodes.get(&inode) {
769                data.inc_ref();
770            }
771        }
772
773        Ok((inode, Self::metadata_to_attr(inode, &metadata)))
774    }
775
776    /// Creates a special file node.
777    ///
778    /// # Errors
779    ///
780    /// - [`FsError::Io`] if the node cannot be created
781    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
782    #[allow(clippy::cast_possible_truncation)]
783    pub fn mknod(
784        &self,
785        parent: u64,
786        name: &OsStr,
787        mode: u32,
788        rdev: u64,
789    ) -> Result<(u64, crate::fuse::FuseAttr)> {
790        let path = self.get_path(parent, name)?;
791        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
792            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
793
794        let ret = unsafe {
795            libc::mknod(
796                path_cstr.as_ptr(),
797                mode as libc::mode_t,
798                rdev as libc::dev_t,
799            )
800        };
801        if ret != 0 {
802            return Err(FsError::io(std::io::Error::last_os_error()));
803        }
804
805        self.invalidate_negative_cache(&path);
806
807        let metadata = std::fs::symlink_metadata(&path).map_err(FsError::io)?;
808        let file_type = FileType::from_mode(metadata.mode());
809        let relative = self.relative_path(&path);
810        let kernel_ino = metadata.ino();
811        let inode = self.alloc_inode();
812
813        {
814            let mut inodes = self
815                .inodes
816                .write()
817                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
818            inodes.insert(inode, InodeData::new(relative, file_type, kernel_ino));
819        }
820
821        Ok((inode, Self::metadata_to_attr(inode, &metadata)))
822    }
823
824    // ========================================================================
825    // File Deletion Operations
826    // ========================================================================
827
828    /// Removes a file.
829    ///
830    /// # Errors
831    ///
832    /// - [`FsError::Io`] if the file cannot be removed
833    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
834    pub fn unlink(&self, parent: u64, name: &OsStr) -> Result<()> {
835        let path = self.get_path(parent, name)?;
836        std::fs::remove_file(&path).map_err(FsError::io)?;
837
838        // The old path should now return ENOENT, so we could add it to
839        // negative cache, but since the file is gone, it's cleaner to
840        // just let it be discovered naturally.
841        Ok(())
842    }
843
844    /// Removes a directory.
845    ///
846    /// # Errors
847    ///
848    /// - [`FsError::Io`] if the directory cannot be removed
849    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
850    pub fn rmdir(&self, parent: u64, name: &OsStr) -> Result<()> {
851        let path = self.get_path(parent, name)?;
852        std::fs::remove_dir(&path).map_err(FsError::io)
853    }
854
855    /// Renames a file or directory.
856    ///
857    /// # Errors
858    ///
859    /// - [`FsError::Io`] if the rename fails
860    /// - [`FsError::InvalidHandle`] if the parent inode is invalid
861    pub fn rename(
862        &self,
863        parent: u64,
864        name: &OsStr,
865        new_parent: u64,
866        new_name: &OsStr,
867        _flags: u32,
868    ) -> Result<()> {
869        let old_path = self.get_path(parent, name)?;
870        let new_path = self.get_path(new_parent, new_name)?;
871
872        std::fs::rename(&old_path, &new_path).map_err(FsError::io)?;
873
874        // Invalidate both paths
875        self.invalidate_negative_cache(&old_path);
876        self.invalidate_negative_cache(&new_path);
877
878        // Update inode path if we have it cached
879        let old_relative = self.relative_path(&old_path);
880        let new_relative = self.relative_path(&new_path);
881
882        {
883            let mut inodes = self
884                .inodes
885                .write()
886                .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
887            for data in inodes.values_mut() {
888                if data.path == old_relative {
889                    data.path = new_relative;
890                    break;
891                }
892            }
893        }
894
895        Ok(())
896    }
897
898    // ========================================================================
899    // File Handle Operations
900    // ========================================================================
901
902    /// Applies POSIX open flags to `OpenOptions`.
903    fn apply_flags(opts: &mut OpenOptions, flags: u32) {
904        let access_mode = flags & libc::O_ACCMODE as u32;
905        match access_mode {
906            x if x == libc::O_RDONLY as u32 => {
907                opts.read(true);
908            }
909            x if x == libc::O_WRONLY as u32 => {
910                opts.write(true);
911            }
912            x if x == libc::O_RDWR as u32 => {
913                opts.read(true).write(true);
914            }
915            _ => {
916                opts.read(true);
917            }
918        }
919
920        if flags & libc::O_APPEND as u32 != 0 {
921            opts.append(true);
922        }
923        if flags & libc::O_TRUNC as u32 != 0 {
924            opts.truncate(true);
925        }
926    }
927
928    /// Opens a file.
929    ///
930    /// # Errors
931    ///
932    /// - [`FsError::Io`] if the file cannot be opened
933    /// - [`FsError::InvalidHandle`] if the inode is invalid
934    pub fn open(&self, inode: u64, flags: u32) -> Result<u64> {
935        let path = self.inode_path(inode)?;
936
937        let mut opts = OpenOptions::new();
938        Self::apply_flags(&mut opts, flags);
939
940        let file = opts.open(&path).map_err(FsError::io)?;
941        let handle = self.alloc_handle();
942
943        {
944            let mut handles = self
945                .handles
946                .write()
947                .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
948            handles.insert(handle, HandleData { file, inode, flags });
949        }
950
951        Ok(handle)
952    }
953
954    /// Reads from a file.
955    ///
956    /// # Errors
957    ///
958    /// - [`FsError::Io`] if read fails
959    /// - [`FsError::InvalidHandle`] if the handle is invalid
960    pub fn read(&self, handle: u64, offset: u64, size: u32) -> Result<Vec<u8>> {
961        let mut handles = self
962            .handles
963            .write()
964            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
965
966        let data = handles
967            .get_mut(&handle)
968            .ok_or(FsError::InvalidHandle(handle))?;
969
970        data.file
971            .seek(SeekFrom::Start(offset))
972            .map_err(FsError::io)?;
973
974        let mut buf = vec![0u8; size as usize];
975        let n = data.file.read(&mut buf).map_err(FsError::io)?;
976        buf.truncate(n);
977
978        Ok(buf)
979    }
980
981    /// Writes to a file.
982    ///
983    /// # Errors
984    ///
985    /// - [`FsError::Io`] if write fails
986    /// - [`FsError::InvalidHandle`] if the handle is invalid
987    pub fn write(&self, handle: u64, offset: u64, data: &[u8], _flags: u32) -> Result<u32> {
988        let mut handles = self
989            .handles
990            .write()
991            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
992
993        let handle_data = handles
994            .get_mut(&handle)
995            .ok_or(FsError::InvalidHandle(handle))?;
996
997        handle_data
998            .file
999            .seek(SeekFrom::Start(offset))
1000            .map_err(FsError::io)?;
1001        let n = handle_data.file.write(data).map_err(FsError::io)?;
1002
1003        #[allow(clippy::cast_possible_truncation)]
1004        Ok(n as u32)
1005    }
1006
1007    /// Flushes a file.
1008    ///
1009    /// # Errors
1010    ///
1011    /// - [`FsError::Io`] if flush fails
1012    /// - [`FsError::InvalidHandle`] if the handle is invalid
1013    pub fn flush(&self, handle: u64) -> Result<()> {
1014        let mut handles = self
1015            .handles
1016            .write()
1017            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
1018
1019        let data = handles
1020            .get_mut(&handle)
1021            .ok_or(FsError::InvalidHandle(handle))?;
1022        data.file.flush().map_err(FsError::io)
1023    }
1024
1025    /// Syncs a file to disk.
1026    ///
1027    /// # Errors
1028    ///
1029    /// - [`FsError::Io`] if sync fails
1030    /// - [`FsError::InvalidHandle`] if the handle is invalid
1031    pub fn fsync(&self, handle: u64, datasync: bool) -> Result<()> {
1032        let handles = self
1033            .handles
1034            .read()
1035            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
1036
1037        let data = handles.get(&handle).ok_or(FsError::InvalidHandle(handle))?;
1038
1039        if datasync {
1040            data.file.sync_data().map_err(FsError::io)
1041        } else {
1042            data.file.sync_all().map_err(FsError::io)
1043        }
1044    }
1045
1046    /// Releases (closes) a file handle.
1047    pub fn release(&self, handle: u64) -> Result<()> {
1048        let mut handles = self
1049            .handles
1050            .write()
1051            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
1052
1053        handles.remove(&handle);
1054        Ok(())
1055    }
1056
1057    /// Returns the raw fd for an open file handle (for DAX mapping).
1058    pub fn get_file_raw_fd(&self, handle: u64) -> Option<std::os::unix::io::RawFd> {
1059        use std::os::unix::io::AsRawFd;
1060        let handles = self.handles.read().ok()?;
1061        handles.get(&handle).map(|h| h.file.as_raw_fd())
1062    }
1063
1064    /// Seeks in a file (lseek).
1065    ///
1066    /// # Errors
1067    ///
1068    /// - [`FsError::Io`] if seek fails
1069    /// - [`FsError::InvalidHandle`] if the handle is invalid
1070    pub fn lseek(&self, handle: u64, offset: i64, whence: u32) -> Result<u64> {
1071        let mut handles = self
1072            .handles
1073            .write()
1074            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
1075
1076        let data = handles
1077            .get_mut(&handle)
1078            .ok_or(FsError::InvalidHandle(handle))?;
1079
1080        let seek_from = match whence {
1081            0 => SeekFrom::Start(offset as u64), // SEEK_SET
1082            1 => SeekFrom::Current(offset),      // SEEK_CUR
1083            2 => SeekFrom::End(offset),          // SEEK_END
1084            _ => return Err(FsError::InvalidPath("invalid whence".to_string())),
1085        };
1086
1087        data.file.seek(seek_from).map_err(FsError::io)
1088    }
1089
1090    /// Allocates space for a file.
1091    ///
1092    /// # Errors
1093    ///
1094    /// - [`FsError::Io`] if allocation fails
1095    /// - [`FsError::InvalidHandle`] if the handle is invalid
1096    #[cfg(target_os = "linux")]
1097    pub fn fallocate(&self, handle: u64, mode: u32, offset: u64, length: u64) -> Result<()> {
1098        let handles = self
1099            .handles
1100            .read()
1101            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
1102
1103        let data = handles.get(&handle).ok_or(FsError::InvalidHandle(handle))?;
1104        let fd = data.file.as_raw_fd();
1105
1106        #[allow(clippy::cast_possible_wrap)]
1107        let ret = unsafe { libc::fallocate(fd, mode as i32, offset as i64, length as i64) };
1108
1109        if ret != 0 {
1110            Err(FsError::io(std::io::Error::last_os_error()))
1111        } else {
1112            Ok(())
1113        }
1114    }
1115
1116    #[cfg(target_os = "macos")]
1117    pub fn fallocate(&self, handle: u64, _mode: u32, offset: u64, length: u64) -> Result<()> {
1118        // macOS doesn't have fallocate, use ftruncate as fallback for simple cases
1119        let mut handles = self
1120            .handles
1121            .write()
1122            .map_err(|_| FsError::Cache("failed to acquire handle lock".to_string()))?;
1123
1124        let data = handles
1125            .get_mut(&handle)
1126            .ok_or(FsError::InvalidHandle(handle))?;
1127        let new_size = offset + length;
1128        data.file.set_len(new_size).map_err(FsError::io)
1129    }
1130
1131    // ========================================================================
1132    // Directory Operations
1133    // ========================================================================
1134
1135    /// Opens a directory for reading.
1136    ///
1137    /// # Errors
1138    ///
1139    /// - [`FsError::Io`] if the directory cannot be opened
1140    /// - [`FsError::InvalidHandle`] if the inode is invalid
1141    pub fn opendir(&self, inode: u64) -> Result<u64> {
1142        let path = self.inode_path(inode)?;
1143
1144        // Read directory entries
1145        let mut entries = Vec::new();
1146
1147        // Add . and ..
1148        entries.push(DirEntry {
1149            name: OsString::from("."),
1150            ino: inode,
1151            file_type: FileType::Directory,
1152        });
1153
1154        // For .., use root inode (parent tracking not yet implemented).
1155        entries.push(DirEntry {
1156            name: OsString::from(".."),
1157            ino: Self::ROOT_INODE,
1158            file_type: FileType::Directory,
1159        });
1160
1161        // Read actual entries
1162        for entry in std::fs::read_dir(&path).map_err(FsError::io)? {
1163            let entry = entry.map_err(FsError::io)?;
1164            let metadata = entry.metadata().map_err(FsError::io)?;
1165            let file_type = FileType::from_mode(metadata.mode());
1166
1167            // Look up or create inode for this entry
1168            let entry_path = entry.path();
1169            let relative = self.relative_path(&entry_path);
1170            let entry_ino = {
1171                let inodes = self
1172                    .inodes
1173                    .read()
1174                    .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
1175                let mut found_ino = None;
1176                for (&ino, data) in inodes.iter() {
1177                    if data.path == relative {
1178                        found_ino = Some(ino);
1179                        break;
1180                    }
1181                }
1182                found_ino
1183            };
1184
1185            let ino = if let Some(ino) = entry_ino {
1186                ino
1187            } else {
1188                // Create new inode
1189                let kernel_ino = metadata.ino();
1190                let new_ino = self.alloc_inode();
1191                let mut inodes = self
1192                    .inodes
1193                    .write()
1194                    .map_err(|_| FsError::Cache("failed to acquire inode lock".to_string()))?;
1195                inodes.insert(new_ino, InodeData::new(relative, file_type, kernel_ino));
1196                new_ino
1197            };
1198
1199            entries.push(DirEntry {
1200                name: entry.file_name(),
1201                ino,
1202                file_type,
1203            });
1204        }
1205
1206        let handle = self.alloc_handle();
1207        {
1208            let mut dir_handles = self
1209                .dir_handles
1210                .write()
1211                .map_err(|_| FsError::Cache("failed to acquire dir handle lock".to_string()))?;
1212            dir_handles.insert(handle, DirHandleData { inode, entries });
1213        }
1214
1215        Ok(handle)
1216    }
1217
1218    /// Reads directory entries.
1219    ///
1220    /// Returns entries starting from `offset`.
1221    ///
1222    /// # Errors
1223    ///
1224    /// - [`FsError::InvalidHandle`] if the handle is invalid
1225    pub fn readdir(&self, handle: u64, offset: u64) -> Result<Vec<DirEntry>> {
1226        let dir_handles = self
1227            .dir_handles
1228            .read()
1229            .map_err(|_| FsError::Cache("failed to acquire dir handle lock".to_string()))?;
1230
1231        let data = dir_handles
1232            .get(&handle)
1233            .ok_or(FsError::InvalidHandle(handle))?;
1234
1235        let entries: Vec<DirEntry> = data.entries.iter().skip(offset as usize).cloned().collect();
1236
1237        Ok(entries)
1238    }
1239
1240    /// Releases (closes) a directory handle.
1241    pub fn releasedir(&self, handle: u64) -> Result<()> {
1242        let mut dir_handles = self
1243            .dir_handles
1244            .write()
1245            .map_err(|_| FsError::Cache("failed to acquire dir handle lock".to_string()))?;
1246
1247        dir_handles.remove(&handle);
1248        Ok(())
1249    }
1250
1251    /// Syncs a directory.
1252    ///
1253    /// # Errors
1254    ///
1255    /// - [`FsError::Io`] if sync fails
1256    /// - [`FsError::InvalidHandle`] if the handle is invalid
1257    pub fn fsyncdir(&self, handle: u64, _datasync: bool) -> Result<()> {
1258        let dir_handles = self
1259            .dir_handles
1260            .read()
1261            .map_err(|_| FsError::Cache("failed to acquire dir handle lock".to_string()))?;
1262
1263        let data = dir_handles
1264            .get(&handle)
1265            .ok_or(FsError::InvalidHandle(handle))?;
1266        let path = self.inode_path(data.inode)?;
1267
1268        // Open directory and sync
1269        let dir = File::open(&path).map_err(FsError::io)?;
1270        dir.sync_all().map_err(FsError::io)
1271    }
1272
1273    // ========================================================================
1274    // Extended Attributes (xattr)
1275    // ========================================================================
1276
1277    /// Gets an extended attribute.
1278    ///
1279    /// # Errors
1280    ///
1281    /// - [`FsError::Io`] if the operation fails
1282    /// - [`FsError::InvalidHandle`] if the inode is invalid
1283    #[cfg(target_os = "linux")]
1284    pub fn getxattr(&self, inode: u64, name: &OsStr, size: u32) -> Result<Vec<u8>> {
1285        use std::os::unix::ffi::OsStrExt;
1286
1287        let path = self.inode_path(inode)?;
1288        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1289            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1290        let name_cstr = std::ffi::CString::new(name.as_bytes())
1291            .map_err(|_| FsError::InvalidPath("invalid name".to_string()))?;
1292
1293        if size == 0 {
1294            // Query size
1295            let ret = unsafe {
1296                libc::getxattr(
1297                    path_cstr.as_ptr(),
1298                    name_cstr.as_ptr(),
1299                    std::ptr::null_mut(),
1300                    0,
1301                )
1302            };
1303            if ret < 0 {
1304                return Err(FsError::io(std::io::Error::last_os_error()));
1305            }
1306            Ok(vec![0u8; ret as usize])
1307        } else {
1308            let mut buf = vec![0u8; size as usize];
1309            let ret = unsafe {
1310                libc::getxattr(
1311                    path_cstr.as_ptr(),
1312                    name_cstr.as_ptr(),
1313                    buf.as_mut_ptr().cast(),
1314                    size as usize,
1315                )
1316            };
1317            if ret < 0 {
1318                return Err(FsError::io(std::io::Error::last_os_error()));
1319            }
1320            buf.truncate(ret as usize);
1321            Ok(buf)
1322        }
1323    }
1324
1325    #[cfg(target_os = "macos")]
1326    pub fn getxattr(&self, inode: u64, name: &OsStr, size: u32) -> Result<Vec<u8>> {
1327        let path = self.inode_path(inode)?;
1328        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1329            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1330        let name_cstr = std::ffi::CString::new(name.as_bytes())
1331            .map_err(|_| FsError::InvalidPath("invalid name".to_string()))?;
1332
1333        if size == 0 {
1334            let ret = unsafe {
1335                libc::getxattr(
1336                    path_cstr.as_ptr(),
1337                    name_cstr.as_ptr(),
1338                    std::ptr::null_mut(),
1339                    0,
1340                    0,
1341                    0,
1342                )
1343            };
1344            if ret < 0 {
1345                return Err(FsError::io(std::io::Error::last_os_error()));
1346            }
1347            Ok(vec![0u8; ret as usize])
1348        } else {
1349            let mut buf = vec![0u8; size as usize];
1350            let ret = unsafe {
1351                libc::getxattr(
1352                    path_cstr.as_ptr(),
1353                    name_cstr.as_ptr(),
1354                    buf.as_mut_ptr().cast(),
1355                    size as usize,
1356                    0,
1357                    0,
1358                )
1359            };
1360            if ret < 0 {
1361                return Err(FsError::io(std::io::Error::last_os_error()));
1362            }
1363            buf.truncate(ret as usize);
1364            Ok(buf)
1365        }
1366    }
1367
1368    /// Sets an extended attribute.
1369    ///
1370    /// # Errors
1371    ///
1372    /// - [`FsError::Io`] if the operation fails
1373    /// - [`FsError::InvalidHandle`] if the inode is invalid
1374    #[cfg(target_os = "linux")]
1375    pub fn setxattr(&self, inode: u64, name: &OsStr, value: &[u8], flags: u32) -> Result<()> {
1376        let path = self.inode_path(inode)?;
1377        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1378            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1379        let name_cstr = std::ffi::CString::new(name.as_bytes())
1380            .map_err(|_| FsError::InvalidPath("invalid name".to_string()))?;
1381
1382        let ret = unsafe {
1383            libc::setxattr(
1384                path_cstr.as_ptr(),
1385                name_cstr.as_ptr(),
1386                value.as_ptr().cast(),
1387                value.len(),
1388                flags as i32,
1389            )
1390        };
1391        if ret != 0 {
1392            Err(FsError::io(std::io::Error::last_os_error()))
1393        } else {
1394            Ok(())
1395        }
1396    }
1397
1398    #[cfg(target_os = "macos")]
1399    pub fn setxattr(&self, inode: u64, name: &OsStr, value: &[u8], flags: u32) -> Result<()> {
1400        let path = self.inode_path(inode)?;
1401        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1402            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1403        let name_cstr = std::ffi::CString::new(name.as_bytes())
1404            .map_err(|_| FsError::InvalidPath("invalid name".to_string()))?;
1405
1406        let ret = unsafe {
1407            libc::setxattr(
1408                path_cstr.as_ptr(),
1409                name_cstr.as_ptr(),
1410                value.as_ptr().cast(),
1411                value.len(),
1412                0,
1413                flags as i32,
1414            )
1415        };
1416        if ret != 0 {
1417            Err(FsError::io(std::io::Error::last_os_error()))
1418        } else {
1419            Ok(())
1420        }
1421    }
1422
1423    /// Removes an extended attribute.
1424    ///
1425    /// # Errors
1426    ///
1427    /// - [`FsError::Io`] if the operation fails
1428    /// - [`FsError::InvalidHandle`] if the inode is invalid
1429    #[cfg(target_os = "linux")]
1430    pub fn removexattr(&self, inode: u64, name: &OsStr) -> Result<()> {
1431        let path = self.inode_path(inode)?;
1432        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1433            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1434        let name_cstr = std::ffi::CString::new(name.as_bytes())
1435            .map_err(|_| FsError::InvalidPath("invalid name".to_string()))?;
1436
1437        let ret = unsafe { libc::removexattr(path_cstr.as_ptr(), name_cstr.as_ptr()) };
1438        if ret != 0 {
1439            Err(FsError::io(std::io::Error::last_os_error()))
1440        } else {
1441            Ok(())
1442        }
1443    }
1444
1445    #[cfg(target_os = "macos")]
1446    pub fn removexattr(&self, inode: u64, name: &OsStr) -> Result<()> {
1447        let path = self.inode_path(inode)?;
1448        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1449            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1450        let name_cstr = std::ffi::CString::new(name.as_bytes())
1451            .map_err(|_| FsError::InvalidPath("invalid name".to_string()))?;
1452
1453        let ret = unsafe { libc::removexattr(path_cstr.as_ptr(), name_cstr.as_ptr(), 0) };
1454        if ret != 0 {
1455            Err(FsError::io(std::io::Error::last_os_error()))
1456        } else {
1457            Ok(())
1458        }
1459    }
1460
1461    // ========================================================================
1462    // Filesystem Information
1463    // ========================================================================
1464
1465    /// Gets filesystem statistics.
1466    ///
1467    /// # Errors
1468    ///
1469    /// - [`FsError::Io`] if statfs fails
1470    pub fn statfs(&self) -> Result<crate::fuse::StatFs> {
1471        let path_cstr = std::ffi::CString::new(self.root.as_os_str().as_bytes())
1472            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1473
1474        let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
1475        let ret = unsafe { libc::statfs(path_cstr.as_ptr(), &raw mut stat) };
1476        if ret != 0 {
1477            return Err(FsError::io(std::io::Error::last_os_error()));
1478        }
1479
1480        #[allow(
1481            clippy::cast_sign_loss,
1482            clippy::cast_possible_truncation,
1483            clippy::unnecessary_cast // Field types differ between macOS (u32) and Linux (u64).
1484        )]
1485        Ok(crate::fuse::StatFs {
1486            blocks: stat.f_blocks as u64,
1487            bfree: stat.f_bfree as u64,
1488            bavail: stat.f_bavail as u64,
1489            files: stat.f_files as u64,
1490            ffree: stat.f_ffree as u64,
1491            bsize: stat.f_bsize as u32,
1492            namelen: 255, // Common limit
1493            frsize: stat.f_bsize as u32,
1494        })
1495    }
1496
1497    /// Checks file access permissions.
1498    ///
1499    /// # Errors
1500    ///
1501    /// - [`FsError::PermissionDenied`] if access is denied
1502    /// - [`FsError::InvalidHandle`] if the inode is invalid
1503    pub fn access(&self, inode: u64, mask: u32) -> Result<()> {
1504        let path = self.inode_path(inode)?;
1505        let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
1506            .map_err(|_| FsError::InvalidPath("invalid path".to_string()))?;
1507
1508        #[allow(clippy::cast_possible_wrap)]
1509        let ret = unsafe { libc::access(path_cstr.as_ptr(), mask as i32) };
1510        if ret != 0 {
1511            let err = std::io::Error::last_os_error();
1512            if err.raw_os_error() == Some(libc::EACCES) {
1513                Err(FsError::permission_denied(path.display().to_string()))
1514            } else {
1515                Err(FsError::io(err))
1516            }
1517        } else {
1518            Ok(())
1519        }
1520    }
1521}
1522
1523// Internal counters and atomics (next_inode, next_handle, negative_cache, config)
1524// are intentionally omitted from Debug — they are implementation details that
1525// add noise without aiding diagnostics.
1526#[allow(clippy::missing_fields_in_debug)]
1527impl std::fmt::Debug for PassthroughFs {
1528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1529        f.debug_struct("PassthroughFs")
1530            .field("root", &self.root)
1531            .field("inodes", &self.inodes.read().map_or(0, |i| i.len()))
1532            .field("handles", &self.handles.read().map_or(0, |h| h.len()))
1533            .field(
1534                "dir_handles",
1535                &self.dir_handles.read().map_or(0, |h| h.len()),
1536            )
1537            .finish()
1538    }
1539}
1540
1541// ============================================================================
1542// Tests
1543// ============================================================================
1544
1545#[cfg(test)]
1546mod tests {
1547    use super::*;
1548    use tempfile::TempDir;
1549
1550    const S_IFDIR: u32 = libc::S_IFDIR as u32;
1551    const S_IFREG: u32 = libc::S_IFREG as u32;
1552
1553    fn setup_test_fs() -> (TempDir, PassthroughFs) {
1554        let temp = TempDir::new().expect("failed to create temp dir");
1555        let fs = PassthroughFs::new(temp.path()).expect("failed to create fs");
1556        (temp, fs)
1557    }
1558
1559    #[test]
1560    fn test_new_filesystem() {
1561        let temp = TempDir::new().unwrap();
1562        let fs = PassthroughFs::new(temp.path()).unwrap();
1563        assert_eq!(fs.root(), temp.path());
1564    }
1565
1566    #[test]
1567    fn test_new_invalid_path() {
1568        let result = PassthroughFs::new("/nonexistent/path/12345");
1569        assert!(result.is_err());
1570    }
1571
1572    #[test]
1573    fn test_lookup_existing_file() {
1574        let (temp, fs) = setup_test_fs();
1575
1576        // Create a test file
1577        let file_path = temp.path().join("test.txt");
1578        std::fs::write(&file_path, "hello").unwrap();
1579
1580        // Lookup should succeed
1581        let result = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new("test.txt"));
1582        assert!(result.is_ok());
1583        let (inode, attr) = result.unwrap();
1584        assert!(inode > PassthroughFs::ROOT_INODE);
1585        assert_eq!(attr.size, 5);
1586    }
1587
1588    #[test]
1589    fn test_lookup_nonexistent_file() {
1590        let (_temp, fs) = setup_test_fs();
1591
1592        let result = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new("nonexistent.txt"));
1593        assert!(result.is_err());
1594        assert!(result.unwrap_err().is_not_found());
1595    }
1596
1597    #[test]
1598    fn test_lookup_negative_cache() {
1599        let (_temp, fs) = setup_test_fs();
1600
1601        // First lookup - should miss
1602        let _ = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new("missing.txt"));
1603
1604        // Check stats
1605        if let Some(cache) = fs.negative_cache() {
1606            let stats = cache.stats();
1607            assert!(stats.entries > 0 || stats.misses > 0);
1608        }
1609
1610        // Second lookup - should hit cache
1611        let result = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new("missing.txt"));
1612        assert!(result.is_err());
1613        assert!(result.unwrap_err().is_not_found());
1614    }
1615
1616    #[test]
1617    fn test_getattr_root() {
1618        let (_temp, fs) = setup_test_fs();
1619
1620        let result = fs.getattr(PassthroughFs::ROOT_INODE);
1621        assert!(result.is_ok());
1622        let attr = result.unwrap();
1623        assert_eq!(attr.ino, PassthroughFs::ROOT_INODE);
1624        assert!(attr.mode & S_IFDIR != 0);
1625    }
1626
1627    #[test]
1628    fn test_create_and_read_file() {
1629        let (_temp, fs) = setup_test_fs();
1630
1631        // Create file
1632        let (inode, _attr, handle) = fs
1633            .create(
1634                PassthroughFs::ROOT_INODE,
1635                OsStr::new("newfile.txt"),
1636                0o644,
1637                libc::O_RDWR as u32,
1638            )
1639            .unwrap();
1640
1641        assert!(inode > PassthroughFs::ROOT_INODE);
1642
1643        // Write to file
1644        let data = b"hello world";
1645        let written = fs.write(handle, 0, data, 0).unwrap();
1646        assert_eq!(written, data.len() as u32);
1647
1648        // Read back
1649        let read_data = fs.read(handle, 0, 100).unwrap();
1650        assert_eq!(read_data, data);
1651
1652        // Release handle
1653        fs.release(handle).unwrap();
1654    }
1655
1656    #[test]
1657    fn test_mkdir_and_rmdir() {
1658        let (_temp, fs) = setup_test_fs();
1659
1660        // Create directory
1661        let (inode, attr) = fs
1662            .mkdir(PassthroughFs::ROOT_INODE, OsStr::new("testdir"), 0o755)
1663            .unwrap();
1664
1665        assert!(inode > PassthroughFs::ROOT_INODE);
1666        assert!(attr.mode & S_IFDIR != 0);
1667
1668        // Remove directory
1669        fs.rmdir(PassthroughFs::ROOT_INODE, OsStr::new("testdir"))
1670            .unwrap();
1671
1672        // Lookup should fail
1673        let result = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new("testdir"));
1674        assert!(result.is_err());
1675        assert!(result.unwrap_err().is_not_found());
1676    }
1677
1678    #[test]
1679    fn test_symlink_and_readlink() {
1680        let (temp, fs) = setup_test_fs();
1681
1682        // Create a target file
1683        let target = temp.path().join("target.txt");
1684        std::fs::write(&target, "target content").unwrap();
1685
1686        // Create symlink
1687        let (inode, _attr) = fs
1688            .symlink(
1689                PassthroughFs::ROOT_INODE,
1690                OsStr::new("link"),
1691                Path::new("target.txt"),
1692            )
1693            .unwrap();
1694
1695        // Read link
1696        let link_target = fs.readlink(inode).unwrap();
1697        assert_eq!(link_target, Path::new("target.txt"));
1698    }
1699
1700    #[test]
1701    fn test_hard_link() {
1702        let (temp, fs) = setup_test_fs();
1703
1704        // Create original file
1705        let original = temp.path().join("original.txt");
1706        std::fs::write(&original, "content").unwrap();
1707
1708        // Lookup original
1709        let (orig_inode, _) = fs
1710            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("original.txt"))
1711            .unwrap();
1712
1713        // Create hard link
1714        let (link_inode, attr) = fs
1715            .link(
1716                orig_inode,
1717                PassthroughFs::ROOT_INODE,
1718                OsStr::new("hardlink.txt"),
1719            )
1720            .unwrap();
1721
1722        // Hard links share the same inode
1723        assert_eq!(link_inode, orig_inode);
1724        assert!(attr.nlink >= 2);
1725    }
1726
1727    #[test]
1728    fn test_unlink() {
1729        let (temp, fs) = setup_test_fs();
1730
1731        // Create file
1732        let file_path = temp.path().join("todelete.txt");
1733        std::fs::write(&file_path, "delete me").unwrap();
1734
1735        // Unlink
1736        fs.unlink(PassthroughFs::ROOT_INODE, OsStr::new("todelete.txt"))
1737            .unwrap();
1738
1739        // File should be gone
1740        assert!(!file_path.exists());
1741    }
1742
1743    #[test]
1744    fn test_rename() {
1745        let (temp, fs) = setup_test_fs();
1746
1747        // Create file
1748        let old_path = temp.path().join("old.txt");
1749        std::fs::write(&old_path, "content").unwrap();
1750
1751        // Rename
1752        fs.rename(
1753            PassthroughFs::ROOT_INODE,
1754            OsStr::new("old.txt"),
1755            PassthroughFs::ROOT_INODE,
1756            OsStr::new("new.txt"),
1757            0,
1758        )
1759        .unwrap();
1760
1761        // Old path should not exist
1762        assert!(!old_path.exists());
1763        // New path should exist
1764        assert!(temp.path().join("new.txt").exists());
1765    }
1766
1767    #[test]
1768    fn test_opendir_readdir() {
1769        let (temp, fs) = setup_test_fs();
1770
1771        // Create some files
1772        std::fs::write(temp.path().join("file1.txt"), "1").unwrap();
1773        std::fs::write(temp.path().join("file2.txt"), "2").unwrap();
1774        std::fs::create_dir(temp.path().join("subdir")).unwrap();
1775
1776        // Open directory
1777        let handle = fs.opendir(PassthroughFs::ROOT_INODE).unwrap();
1778
1779        // Read entries
1780        let entries = fs.readdir(handle, 0).unwrap();
1781
1782        // Should have at least . .. and our 3 entries
1783        assert!(entries.len() >= 5);
1784
1785        // Check for expected names
1786        let names: Vec<_> = entries
1787            .iter()
1788            .map(|e| e.name.to_string_lossy().to_string())
1789            .collect();
1790        assert!(names.contains(&".".to_string()));
1791        assert!(names.contains(&"..".to_string()));
1792        assert!(names.contains(&"file1.txt".to_string()));
1793        assert!(names.contains(&"file2.txt".to_string()));
1794        assert!(names.contains(&"subdir".to_string()));
1795
1796        // Release
1797        fs.releasedir(handle).unwrap();
1798    }
1799
1800    #[test]
1801    fn test_setattr_size() {
1802        let (temp, fs) = setup_test_fs();
1803
1804        // Create file with content
1805        let file_path = temp.path().join("truncate.txt");
1806        std::fs::write(&file_path, "hello world").unwrap();
1807
1808        let (inode, _) = fs
1809            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("truncate.txt"))
1810            .unwrap();
1811
1812        // Truncate to 5 bytes
1813        let attr = fs
1814            .setattr(inode, None, None, None, Some(5), None, None)
1815            .unwrap();
1816        assert_eq!(attr.size, 5);
1817
1818        // Verify content
1819        let content = std::fs::read_to_string(&file_path).unwrap();
1820        assert_eq!(content, "hello");
1821    }
1822
1823    #[test]
1824    fn test_setattr_mode() {
1825        let (temp, fs) = setup_test_fs();
1826
1827        let file_path = temp.path().join("chmod.txt");
1828        std::fs::write(&file_path, "test").unwrap();
1829
1830        let (inode, _) = fs
1831            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("chmod.txt"))
1832            .unwrap();
1833
1834        // Change mode
1835        let attr = fs
1836            .setattr(inode, Some(0o600), None, None, None, None, None)
1837            .unwrap();
1838        assert_eq!(attr.mode & 0o777, 0o600);
1839    }
1840
1841    #[test]
1842    fn test_open_read_write() {
1843        let (temp, fs) = setup_test_fs();
1844
1845        // Create file
1846        let file_path = temp.path().join("rw.txt");
1847        std::fs::write(&file_path, "initial").unwrap();
1848
1849        let (inode, _) = fs
1850            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("rw.txt"))
1851            .unwrap();
1852
1853        // Open for read/write
1854        let handle = fs.open(inode, libc::O_RDWR as u32).unwrap();
1855
1856        // Read
1857        let data = fs.read(handle, 0, 100).unwrap();
1858        assert_eq!(data, b"initial");
1859
1860        // Write at offset
1861        fs.write(handle, 0, b"INITIAL", 0).unwrap();
1862
1863        // Read again
1864        let data = fs.read(handle, 0, 100).unwrap();
1865        assert_eq!(data, b"INITIAL");
1866
1867        fs.release(handle).unwrap();
1868    }
1869
1870    #[test]
1871    fn test_fsync() {
1872        let (temp, fs) = setup_test_fs();
1873
1874        let file_path = temp.path().join("sync.txt");
1875        std::fs::write(&file_path, "test").unwrap();
1876
1877        let (inode, _) = fs
1878            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("sync.txt"))
1879            .unwrap();
1880
1881        let handle = fs.open(inode, libc::O_RDWR as u32).unwrap();
1882        fs.write(handle, 0, b"updated", 0).unwrap();
1883
1884        // Sync should succeed
1885        fs.fsync(handle, false).unwrap();
1886        fs.fsync(handle, true).unwrap();
1887
1888        fs.release(handle).unwrap();
1889    }
1890
1891    #[test]
1892    fn test_flush() {
1893        let (temp, fs) = setup_test_fs();
1894
1895        let file_path = temp.path().join("flush.txt");
1896        std::fs::write(&file_path, "test").unwrap();
1897
1898        let (inode, _) = fs
1899            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("flush.txt"))
1900            .unwrap();
1901
1902        let handle = fs.open(inode, libc::O_RDWR as u32).unwrap();
1903        fs.write(handle, 0, b"updated", 0).unwrap();
1904        fs.flush(handle).unwrap();
1905        fs.release(handle).unwrap();
1906    }
1907
1908    #[test]
1909    fn test_lseek() {
1910        let (temp, fs) = setup_test_fs();
1911
1912        let file_path = temp.path().join("seek.txt");
1913        std::fs::write(&file_path, "0123456789").unwrap();
1914
1915        let (inode, _) = fs
1916            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("seek.txt"))
1917            .unwrap();
1918
1919        let handle = fs.open(inode, libc::O_RDONLY as u32).unwrap();
1920
1921        // Seek to offset 5
1922        let pos = fs.lseek(handle, 5, 0).unwrap(); // SEEK_SET
1923        assert_eq!(pos, 5);
1924
1925        // Read from there
1926        let data = fs.read(handle, pos, 5).unwrap();
1927        assert_eq!(data, b"56789");
1928
1929        fs.release(handle).unwrap();
1930    }
1931
1932    #[test]
1933    fn test_statfs() {
1934        let (_temp, fs) = setup_test_fs();
1935
1936        let stat = fs.statfs().unwrap();
1937        assert!(stat.blocks > 0);
1938        assert!(stat.bsize > 0);
1939    }
1940
1941    #[test]
1942    fn test_access() {
1943        let (temp, fs) = setup_test_fs();
1944
1945        let file_path = temp.path().join("access.txt");
1946        std::fs::write(&file_path, "test").unwrap();
1947
1948        let (inode, _) = fs
1949            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("access.txt"))
1950            .unwrap();
1951
1952        // Should be readable
1953        fs.access(inode, libc::R_OK as u32).unwrap();
1954
1955        // Should be writable
1956        fs.access(inode, libc::W_OK as u32).unwrap();
1957    }
1958
1959    #[test]
1960    fn test_forget() {
1961        let (temp, fs) = setup_test_fs();
1962
1963        let file_path = temp.path().join("forget.txt");
1964        std::fs::write(&file_path, "test").unwrap();
1965
1966        let (inode, _) = fs
1967            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("forget.txt"))
1968            .unwrap();
1969
1970        // Lookup again to increase refcount
1971        let (inode2, _) = fs
1972            .lookup(PassthroughFs::ROOT_INODE, OsStr::new("forget.txt"))
1973            .unwrap();
1974        assert_eq!(inode, inode2);
1975
1976        // Forget once
1977        fs.forget(inode, 1);
1978
1979        // Should still be in table
1980        assert!(fs.getattr(inode).is_ok());
1981
1982        // Forget again
1983        fs.forget(inode, 1);
1984
1985        // May or may not be removed depending on timing
1986    }
1987
1988    #[test]
1989    #[ignore = "mknod requires elevated permissions on macOS"]
1990    fn test_mknod_regular_file() {
1991        let (_temp, fs) = setup_test_fs();
1992
1993        // Create regular file via mknod
1994        let (inode, attr) = fs
1995            .mknod(
1996                PassthroughFs::ROOT_INODE,
1997                OsStr::new("mknod_file"),
1998                S_IFREG | 0o644,
1999                0,
2000            )
2001            .unwrap();
2002
2003        assert!(inode > PassthroughFs::ROOT_INODE);
2004        assert!(attr.mode & S_IFREG != 0);
2005    }
2006
2007    #[test]
2008    fn test_concurrent_operations() {
2009        use std::sync::Arc;
2010        use std::thread;
2011
2012        let (temp, fs) = setup_test_fs();
2013        let fs = Arc::new(fs);
2014
2015        // Create some initial files
2016        for i in 0..10 {
2017            std::fs::write(
2018                temp.path().join(format!("file{i}.txt")),
2019                format!("content{i}"),
2020            )
2021            .unwrap();
2022        }
2023
2024        let mut handles = vec![];
2025
2026        // Spawn threads doing lookups
2027        for i in 0..4 {
2028            let fs = Arc::clone(&fs);
2029            handles.push(thread::spawn(move || {
2030                for j in 0..100 {
2031                    let name = format!("file{}.txt", (i + j) % 10);
2032                    let _ = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new(&name));
2033                }
2034            }));
2035        }
2036
2037        // Spawn threads doing reads
2038        for i in 0..4 {
2039            let fs = Arc::clone(&fs);
2040            handles.push(thread::spawn(move || {
2041                for j in 0..50 {
2042                    let name = format!("file{}.txt", (i + j) % 10);
2043                    if let Ok((inode, _)) = fs.lookup(PassthroughFs::ROOT_INODE, OsStr::new(&name))
2044                    {
2045                        if let Ok(handle) = fs.open(inode, libc::O_RDONLY as u32) {
2046                            let _ = fs.read(handle, 0, 100);
2047                            let _ = fs.release(handle);
2048                        }
2049                    }
2050                }
2051            }));
2052        }
2053
2054        for handle in handles {
2055            handle.join().expect("Thread panicked");
2056        }
2057    }
2058}