embeddenator_fs/fs/
fuse_shim.rs

1//! FUSE Filesystem Shim for Holographic Engrams
2//!
3//! This module provides kernel integration via FUSE (Filesystem in Userspace),
4//! allowing engrams to be mounted as native filesystems.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌────────────────────────────────────────────────────────────────┐
10//! │                     User Applications                          │
11//! │                    (read, write, stat, etc.)                   │
12//! └────────────────────────────────────────────────────────────────┘
13//!                                  │ VFS syscalls
14//!                                  ▼
15//! ┌────────────────────────────────────────────────────────────────┐
16//! │                     Linux Kernel VFS                           │
17//! └────────────────────────────────────────────────────────────────┘
18//!                                  │ FUSE protocol
19//!                                  ▼
20//! ┌────────────────────────────────────────────────────────────────┐
21//! │                   /dev/fuse character device                   │
22//! └────────────────────────────────────────────────────────────────┘
23//!                                  │ libfuse / fuser crate
24//!                                  ▼
25//! ┌────────────────────────────────────────────────────────────────┐
26//! │                    EngramFS (this module)                      │
27//! │  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
28//! │  │   Manifest   │  │   Codebook   │  │   Differential       │  │
29//! │  │   (paths)    │  │   (private)  │  │   Decoder            │  │
30//! │  └──────────────┘  └──────────────┘  └──────────────────────┘  │
31//! └────────────────────────────────────────────────────────────────┘
32//!                                  │
33//!                                  ▼
34//! ┌────────────────────────────────────────────────────────────────┐
35//! │                    Engram Storage                              │
36//! │            (on-disk or memory-mapped .engram file)             │
37//! └────────────────────────────────────────────────────────────────┘
38//! ```
39//!
40//! # Usage
41//!
42//! ```bash
43//! # Mount an engram (requires --features fuse)
44//! embeddenator mount --engram root.engram --manifest manifest.json /mnt/engram
45//!
46//! # Access files normally
47//! ls /mnt/engram
48//! cat /mnt/engram/some/file.txt
49//!
50//! # Unmount
51//! fusermount -u /mnt/engram
52//! ```
53//!
54//! # Feature Flag
55//!
56//! The FUSE integration requires the `fuse` feature to be enabled:
57//!
58//! ```toml
59//! [dependencies]
60//! embeddenator = { version = "0.2", features = ["fuse"] }
61//! ```
62
63use std::collections::HashMap;
64use std::sync::{Arc, RwLock};
65use std::time::{Duration, SystemTime, UNIX_EPOCH};
66
67#[cfg(feature = "fuse")]
68use std::ffi::OsStr;
69
70#[cfg(feature = "fuse")]
71use std::path::Path;
72
73/// Inode number type (matches fuser's u64 inode convention)
74pub type Ino = u64;
75
76/// Root inode number (FUSE convention: inode 1 is root)
77pub const ROOT_INO: Ino = 1;
78
79/// File attributes for FUSE
80///
81/// This mirrors fuser::FileAttr but is always available regardless
82/// of feature flags, allowing the core filesystem logic to work
83/// without the fuser crate.
84#[derive(Clone, Debug)]
85pub struct FileAttr {
86    /// Inode number
87    pub ino: Ino,
88    /// File size in bytes
89    pub size: u64,
90    /// Number of 512-byte blocks allocated
91    pub blocks: u64,
92    /// Last access time
93    pub atime: SystemTime,
94    /// Last modification time
95    pub mtime: SystemTime,
96    /// Last status change time
97    pub ctime: SystemTime,
98    /// Creation time (macOS only)
99    pub crtime: SystemTime,
100    /// File type
101    pub kind: FileKind,
102    /// Permissions (mode & 0o7777)
103    pub perm: u16,
104    /// Hard link count
105    pub nlink: u32,
106    /// User ID of owner
107    pub uid: u32,
108    /// Group ID of owner
109    pub gid: u32,
110    /// Device ID (for special files)
111    pub rdev: u32,
112    /// Block size for filesystem I/O
113    pub blksize: u32,
114    /// Flags (macOS only)
115    pub flags: u32,
116}
117
118impl Default for FileAttr {
119    fn default() -> Self {
120        let now = SystemTime::now();
121        FileAttr {
122            ino: 0,
123            size: 0,
124            blocks: 0,
125            atime: now,
126            mtime: now,
127            ctime: now,
128            crtime: now,
129            kind: FileKind::RegularFile,
130            perm: 0o644,
131            nlink: 1,
132            uid: unsafe { libc::getuid() },
133            gid: unsafe { libc::getgid() },
134            rdev: 0,
135            blksize: 4096,
136            flags: 0,
137        }
138    }
139}
140
141#[cfg(feature = "fuse")]
142impl From<FileAttr> for fuser::FileAttr {
143    fn from(attr: FileAttr) -> Self {
144        fuser::FileAttr {
145            ino: attr.ino,
146            size: attr.size,
147            blocks: attr.blocks,
148            atime: attr.atime,
149            mtime: attr.mtime,
150            ctime: attr.ctime,
151            crtime: attr.crtime,
152            kind: attr.kind.into(),
153            perm: attr.perm,
154            nlink: attr.nlink,
155            uid: attr.uid,
156            gid: attr.gid,
157            rdev: attr.rdev,
158            blksize: attr.blksize,
159            flags: attr.flags,
160        }
161    }
162}
163
164/// File type
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum FileKind {
167    /// Directory
168    Directory,
169    /// Regular file
170    RegularFile,
171    /// Symbolic link
172    Symlink,
173}
174
175#[cfg(feature = "fuse")]
176impl From<FileKind> for fuser::FileType {
177    fn from(kind: FileKind) -> Self {
178        match kind {
179            FileKind::Directory => fuser::FileType::Directory,
180            FileKind::RegularFile => fuser::FileType::RegularFile,
181            FileKind::Symlink => fuser::FileType::Symlink,
182        }
183    }
184}
185
186/// Directory entry
187#[derive(Clone, Debug)]
188pub struct DirEntry {
189    /// Inode number
190    pub ino: Ino,
191    /// Entry name
192    pub name: String,
193    /// Entry type
194    pub kind: FileKind,
195}
196
197/// Cached file data for read operations
198#[derive(Clone)]
199pub struct CachedFile {
200    /// File content
201    pub data: Vec<u8>,
202    /// File attributes
203    pub attr: FileAttr,
204}
205
206/// The EngramFS FUSE filesystem implementation
207///
208/// This provides a read-only view of decoded engram data as a standard
209/// POSIX filesystem. Files are decoded on-demand from the holographic
210/// representation and cached for efficient repeated access.
211pub struct EngramFS {
212    /// Inode to file attributes mapping
213    inodes: Arc<RwLock<HashMap<Ino, FileAttr>>>,
214
215    /// Inode to path mapping
216    inode_paths: Arc<RwLock<HashMap<Ino, String>>>,
217
218    /// Path to inode mapping
219    path_inodes: Arc<RwLock<HashMap<String, Ino>>>,
220
221    /// Directory contents (parent_ino -> entries)
222    directories: Arc<RwLock<HashMap<Ino, Vec<DirEntry>>>>,
223
224    /// Cached file data (ino -> data)
225    file_cache: Arc<RwLock<HashMap<Ino, CachedFile>>>,
226
227    /// Next available inode number
228    next_ino: Arc<RwLock<Ino>>,
229
230    /// Read-only mode
231    read_only: bool,
232
233    /// TTL for cached attributes
234    attr_ttl: Duration,
235
236    /// TTL for cached entries
237    entry_ttl: Duration,
238}
239
240impl EngramFS {
241    /// Create a new EngramFS instance
242    ///
243    /// # Arguments
244    ///
245    /// * `read_only` - Whether the filesystem is read-only (default: true for engrams)
246    pub fn new(read_only: bool) -> Self {
247        let mut fs = EngramFS {
248            inodes: Arc::new(RwLock::new(HashMap::new())),
249            inode_paths: Arc::new(RwLock::new(HashMap::new())),
250            path_inodes: Arc::new(RwLock::new(HashMap::new())),
251            directories: Arc::new(RwLock::new(HashMap::new())),
252            file_cache: Arc::new(RwLock::new(HashMap::new())),
253            next_ino: Arc::new(RwLock::new(2)), // Start after root
254            read_only,
255            attr_ttl: Duration::from_secs(1),
256            entry_ttl: Duration::from_secs(1),
257        };
258
259        // Initialize root directory
260        fs.init_root();
261        fs
262    }
263
264    /// Initialize root directory
265    fn init_root(&mut self) {
266        let root_attr = FileAttr {
267            ino: ROOT_INO,
268            size: 0,
269            blocks: 0,
270            kind: FileKind::Directory,
271            perm: 0o755,
272            nlink: 2,
273            ..Default::default()
274        };
275
276        // SAFETY: init_root only called during construction, before any concurrent access
277        // If locks are poisoned here, the filesystem is unrecoverable anyway
278        self.inodes
279            .write()
280            .expect("Lock poisoned during init")
281            .insert(ROOT_INO, root_attr);
282        self.inode_paths
283            .write()
284            .expect("Lock poisoned during init")
285            .insert(ROOT_INO, "/".to_string());
286        self.path_inodes
287            .write()
288            .expect("Lock poisoned during init")
289            .insert("/".to_string(), ROOT_INO);
290        self.directories
291            .write()
292            .expect("Lock poisoned during init")
293            .insert(ROOT_INO, Vec::new());
294    }
295
296    /// Allocate a new inode number
297    fn alloc_ino(&self) -> Result<Ino, &'static str> {
298        let mut next = self
299            .next_ino
300            .write()
301            .map_err(|_| "Inode allocator lock poisoned")?;
302        let ino = *next;
303        *next += 1;
304        Ok(ino)
305    }
306
307    /// Add a file to the filesystem
308    ///
309    /// # Arguments
310    ///
311    /// * `path` - Absolute path within the filesystem (e.g., "/foo/bar.txt")
312    /// * `data` - File content bytes
313    ///
314    /// # Returns
315    ///
316    /// The assigned inode number for the new file
317    pub fn add_file(&self, path: &str, data: Vec<u8>) -> Result<Ino, &'static str> {
318        let path = normalize_path(path);
319
320        // Check if already exists
321        let path_inodes = self.path_inodes.read().unwrap_or_else(|poisoned| {
322            eprintln!("WARNING: path_inodes lock poisoned, recovering...");
323            poisoned.into_inner()
324        });
325        if path_inodes.contains_key(&path) {
326            return Err("File already exists");
327        }
328        drop(path_inodes);
329
330        // Ensure parent directory exists
331        let parent_path = parent_path(&path).ok_or("Invalid path")?;
332        let parent_ino = self.ensure_directory(&parent_path)?;
333
334        // Create file
335        let ino = self.alloc_ino()?;
336        let size = data.len() as u64;
337
338        let attr = FileAttr {
339            ino,
340            size,
341            blocks: size.div_ceil(512),
342            kind: FileKind::RegularFile,
343            perm: 0o644,
344            nlink: 1,
345            ..Default::default()
346        };
347
348        // Store file
349        self.inodes
350            .write()
351            .map_err(|_| "Inodes lock poisoned")?
352            .insert(ino, attr.clone());
353        self.inode_paths
354            .write()
355            .map_err(|_| "Inode paths lock poisoned")?
356            .insert(ino, path.clone());
357        self.path_inodes
358            .write()
359            .map_err(|_| "Path inodes lock poisoned")?
360            .insert(path.clone(), ino);
361        self.file_cache
362            .write()
363            .map_err(|_| "File cache lock poisoned")?
364            .insert(ino, CachedFile { data, attr });
365
366        // Add to parent directory
367        let filename = filename(&path).ok_or("Invalid filename")?;
368        self.directories
369            .write()
370            .map_err(|_| "Directories lock poisoned")?
371            .get_mut(&parent_ino)
372            .ok_or("Parent directory not found")?
373            .push(DirEntry {
374                ino,
375                name: filename.to_string(),
376                kind: FileKind::RegularFile,
377            });
378
379        Ok(ino)
380    }
381
382    /// Ensure a directory exists, creating it if necessary
383    fn ensure_directory(&self, path: &str) -> Result<Ino, &'static str> {
384        let path = normalize_path(path);
385
386        // Root always exists
387        if path == "/" {
388            return Ok(ROOT_INO);
389        }
390
391        // Check if already exists
392        let path_inodes = self.path_inodes.read().unwrap_or_else(|poisoned| {
393            eprintln!("WARNING: path_inodes lock poisoned in ensure_directory, recovering...");
394            poisoned.into_inner()
395        });
396        if let Some(&ino) = path_inodes.get(&path) {
397            return Ok(ino);
398        }
399        drop(path_inodes);
400
401        // Create parent first
402        let parent_path = parent_path(&path).ok_or("Invalid path")?;
403        let parent_ino = self.ensure_directory(&parent_path)?;
404
405        // Create this directory
406        let ino = self.alloc_ino()?;
407        let attr = FileAttr {
408            ino,
409            size: 0,
410            blocks: 0,
411            kind: FileKind::Directory,
412            perm: 0o755,
413            nlink: 2,
414            ..Default::default()
415        };
416
417        self.inodes
418            .write()
419            .map_err(|_| "Inodes lock poisoned")?
420            .insert(ino, attr);
421        self.inode_paths
422            .write()
423            .map_err(|_| "Inode paths lock poisoned")?
424            .insert(ino, path.clone());
425        self.path_inodes
426            .write()
427            .map_err(|_| "Path inodes lock poisoned")?
428            .insert(path.clone(), ino);
429        self.directories
430            .write()
431            .map_err(|_| "Directories lock poisoned")?
432            .insert(ino, Vec::new());
433
434        // Add to parent
435        let dirname = filename(&path).ok_or("Invalid dirname")?;
436        self.directories
437            .write()
438            .map_err(|_| "Directories lock poisoned")?
439            .get_mut(&parent_ino)
440            .ok_or("Parent not found")?
441            .push(DirEntry {
442                ino,
443                name: dirname.to_string(),
444                kind: FileKind::Directory,
445            });
446
447        // Update parent nlink
448        if let Some(parent_attr) = self
449            .inodes
450            .write()
451            .map_err(|_| "Inodes lock poisoned")?
452            .get_mut(&parent_ino)
453        {
454            parent_attr.nlink += 1;
455        }
456
457        Ok(ino)
458    }
459
460    /// Lookup a path and return its inode
461    pub fn lookup_path(&self, path: &str) -> Option<Ino> {
462        let path = normalize_path(path);
463        let path_inodes = self.path_inodes.read().unwrap_or_else(|poisoned| {
464            eprintln!("WARNING: path_inodes lock poisoned in lookup_path, recovering...");
465            poisoned.into_inner()
466        });
467        path_inodes.get(&path).copied()
468    }
469
470    /// Get file attributes by inode
471    pub fn get_attr(&self, ino: Ino) -> Option<FileAttr> {
472        let inodes = self.inodes.read().unwrap_or_else(|poisoned| {
473            eprintln!("WARNING: inodes lock poisoned in get_attr, recovering...");
474            poisoned.into_inner()
475        });
476        inodes.get(&ino).cloned()
477    }
478
479    /// Read file data
480    pub fn read_data(&self, ino: Ino, offset: u64, size: u32) -> Option<Vec<u8>> {
481        let cache = self.file_cache.read().unwrap_or_else(|poisoned| {
482            eprintln!("WARNING: file_cache lock poisoned in read_data, recovering...");
483            poisoned.into_inner()
484        });
485        let cached = cache.get(&ino)?;
486
487        let start = offset as usize;
488        let end = std::cmp::min(start + size as usize, cached.data.len());
489
490        if start >= cached.data.len() {
491            return Some(Vec::new());
492        }
493
494        Some(cached.data[start..end].to_vec())
495    }
496
497    /// Read directory contents
498    pub fn read_dir(&self, ino: Ino) -> Option<Vec<DirEntry>> {
499        let directories = self.directories.read().unwrap_or_else(|poisoned| {
500            eprintln!("WARNING: directories lock poisoned in read_dir, recovering...");
501            poisoned.into_inner()
502        });
503        directories.get(&ino).cloned()
504    }
505
506    /// Lookup entry in directory by name
507    pub fn lookup_entry(&self, parent_ino: Ino, name: &str) -> Option<Ino> {
508        let dirs = self.directories.read().unwrap_or_else(|poisoned| {
509            eprintln!("WARNING: directories lock poisoned in lookup_entry, recovering...");
510            poisoned.into_inner()
511        });
512        let entries = dirs.get(&parent_ino)?;
513        entries.iter().find(|e| e.name == name).map(|e| e.ino)
514    }
515
516    /// Get parent inode for a given inode
517    pub fn get_parent(&self, ino: Ino) -> Option<Ino> {
518        if ino == ROOT_INO {
519            return Some(ROOT_INO); // Root's parent is itself
520        }
521
522        let paths = self.inode_paths.read().unwrap_or_else(|poisoned| {
523            eprintln!("WARNING: inode_paths lock poisoned in get_parent, recovering...");
524            poisoned.into_inner()
525        });
526        let path = paths.get(&ino)?;
527        let parent = parent_path(path)?;
528
529        let path_inodes = self.path_inodes.read().unwrap_or_else(|poisoned| {
530            eprintln!("WARNING: path_inodes lock poisoned in get_parent, recovering...");
531            poisoned.into_inner()
532        });
533        path_inodes.get(&parent).copied()
534    }
535
536    /// Get total number of files
537    pub fn file_count(&self) -> usize {
538        let cache = self.file_cache.read().unwrap_or_else(|poisoned| {
539            eprintln!("WARNING: file_cache lock poisoned in file_count, recovering...");
540            poisoned.into_inner()
541        });
542        cache.len()
543    }
544
545    /// Get total size of all files
546    pub fn total_size(&self) -> u64 {
547        let cache = self.file_cache.read().unwrap_or_else(|poisoned| {
548            eprintln!("WARNING: file_cache lock poisoned in total_size, recovering...");
549            poisoned.into_inner()
550        });
551        cache.values().map(|f| f.attr.size).sum()
552    }
553
554    /// Check if filesystem is read-only
555    pub fn is_read_only(&self) -> bool {
556        self.read_only
557    }
558
559    /// Get attribute TTL
560    pub fn attr_ttl(&self) -> Duration {
561        self.attr_ttl
562    }
563
564    /// Get entry TTL
565    pub fn entry_ttl(&self) -> Duration {
566        self.entry_ttl
567    }
568}
569
570// =============================================================================
571// FUSER FILESYSTEM TRAIT IMPLEMENTATION
572// =============================================================================
573
574#[cfg(feature = "fuse")]
575impl fuser::Filesystem for EngramFS {
576    /// Initialize filesystem
577    fn init(
578        &mut self,
579        _req: &fuser::Request<'_>,
580        _config: &mut fuser::KernelConfig,
581    ) -> Result<(), libc::c_int> {
582        eprintln!(
583            "EngramFS initialized: {} files, {} bytes total",
584            self.file_count(),
585            self.total_size()
586        );
587        Ok(())
588    }
589
590    /// Clean up filesystem
591    fn destroy(&mut self) {
592        eprintln!("EngramFS unmounted");
593    }
594
595    /// Look up a directory entry by name
596    fn lookup(
597        &mut self,
598        _req: &fuser::Request<'_>,
599        parent: u64,
600        name: &OsStr,
601        reply: fuser::ReplyEntry,
602    ) {
603        let name = match name.to_str() {
604            Some(n) => n,
605            None => {
606                reply.error(libc::ENOENT);
607                return;
608            }
609        };
610
611        match self.lookup_entry(parent, name) {
612            Some(ino) => {
613                if let Some(attr) = self.get_attr(ino) {
614                    let fuser_attr: fuser::FileAttr = attr.into();
615                    reply.entry(&self.entry_ttl, &fuser_attr, 0);
616                } else {
617                    reply.error(libc::ENOENT);
618                }
619            }
620            None => {
621                reply.error(libc::ENOENT);
622            }
623        }
624    }
625
626    /// Get file attributes
627    fn getattr(
628        &mut self,
629        _req: &fuser::Request<'_>,
630        ino: u64,
631        _fh: Option<u64>,
632        reply: fuser::ReplyAttr,
633    ) {
634        match self.get_attr(ino) {
635            Some(attr) => {
636                let fuser_attr: fuser::FileAttr = attr.into();
637                reply.attr(&self.attr_ttl, &fuser_attr);
638            }
639            None => {
640                reply.error(libc::ENOENT);
641            }
642        }
643    }
644
645    /// Read data from a file
646    fn read(
647        &mut self,
648        _req: &fuser::Request<'_>,
649        ino: u64,
650        _fh: u64,
651        offset: i64,
652        size: u32,
653        _flags: i32,
654        _lock_owner: Option<u64>,
655        reply: fuser::ReplyData,
656    ) {
657        match self.read_data(ino, offset as u64, size) {
658            Some(data) => {
659                reply.data(&data);
660            }
661            None => {
662                reply.error(libc::ENOENT);
663            }
664        }
665    }
666
667    /// Open a file
668    fn open(&mut self, _req: &fuser::Request<'_>, ino: u64, flags: i32, reply: fuser::ReplyOpen) {
669        // Check if file exists
670        if self.get_attr(ino).is_none() {
671            reply.error(libc::ENOENT);
672            return;
673        }
674
675        // Check for write flags on read-only filesystem
676        if self.read_only {
677            let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC;
678            if flags & write_flags != 0 {
679                reply.error(libc::EROFS);
680                return;
681            }
682        }
683
684        // Return a dummy file handle (we're stateless)
685        reply.opened(0, 0);
686    }
687
688    /// Release an open file
689    fn release(
690        &mut self,
691        _req: &fuser::Request<'_>,
692        _ino: u64,
693        _fh: u64,
694        _flags: i32,
695        _lock_owner: Option<u64>,
696        _flush: bool,
697        reply: fuser::ReplyEmpty,
698    ) {
699        reply.ok();
700    }
701
702    /// Open a directory
703    fn opendir(
704        &mut self,
705        _req: &fuser::Request<'_>,
706        ino: u64,
707        _flags: i32,
708        reply: fuser::ReplyOpen,
709    ) {
710        match self.get_attr(ino) {
711            Some(attr) if attr.kind == FileKind::Directory => {
712                reply.opened(0, 0);
713            }
714            Some(_) => {
715                reply.error(libc::ENOTDIR);
716            }
717            None => {
718                reply.error(libc::ENOENT);
719            }
720        }
721    }
722
723    /// Read directory entries
724    fn readdir(
725        &mut self,
726        _req: &fuser::Request<'_>,
727        ino: u64,
728        _fh: u64,
729        offset: i64,
730        mut reply: fuser::ReplyDirectory,
731    ) {
732        let mut entries: Vec<(u64, fuser::FileType, String)> = Vec::new();
733
734        // Add . and ..
735        entries.push((ino, fuser::FileType::Directory, ".".to_string()));
736        let parent_ino = self.get_parent(ino).unwrap_or(ino);
737        entries.push((parent_ino, fuser::FileType::Directory, "..".to_string()));
738
739        // Add directory contents
740        if let Some(dir_entries) = self.read_dir(ino) {
741            for entry in dir_entries {
742                entries.push((entry.ino, entry.kind.into(), entry.name));
743            }
744        }
745
746        // Skip entries before offset and emit remaining
747        for (i, (ino, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) {
748            // Reply returns true if buffer is full
749            if reply.add(ino, (i + 1) as i64, kind, &name) {
750                break;
751            }
752        }
753
754        reply.ok();
755    }
756
757    /// Release a directory handle
758    fn releasedir(
759        &mut self,
760        _req: &fuser::Request<'_>,
761        _ino: u64,
762        _fh: u64,
763        _flags: i32,
764        reply: fuser::ReplyEmpty,
765    ) {
766        reply.ok();
767    }
768
769    /// Get filesystem statistics
770    fn statfs(&mut self, _req: &fuser::Request<'_>, _ino: u64, reply: fuser::ReplyStatfs) {
771        let total_files = self.file_count() as u64;
772        let total_size = self.total_size();
773        let block_size = 4096u64;
774        let total_blocks = (total_size + block_size - 1) / block_size;
775
776        reply.statfs(
777            total_blocks,      // blocks - total data blocks
778            0,                 // bfree - free blocks (0 for read-only)
779            0,                 // bavail - available blocks (0 for read-only)
780            total_files,       // files - total file nodes
781            0,                 // ffree - free file nodes (0 for read-only)
782            block_size as u32, // bsize - block size
783            255,               // namelen - maximum name length
784            block_size as u32, // frsize - fragment size
785        );
786    }
787
788    /// Check file access permissions
789    fn access(&mut self, _req: &fuser::Request<'_>, ino: u64, mask: i32, reply: fuser::ReplyEmpty) {
790        // Check if file exists
791        if self.get_attr(ino).is_none() {
792            reply.error(libc::ENOENT);
793            return;
794        }
795
796        // Deny write access on read-only filesystem
797        if self.read_only && (mask & libc::W_OK != 0) {
798            reply.error(libc::EROFS);
799            return;
800        }
801
802        // Allow all other access (simplified permission model)
803        reply.ok();
804    }
805
806    /// Read symbolic link target
807    fn readlink(&mut self, _req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyData) {
808        // We don't support symlinks yet
809        match self.get_attr(ino) {
810            Some(attr) if attr.kind == FileKind::Symlink => {
811                reply.error(libc::ENOSYS); // Not implemented
812            }
813            Some(_) => {
814                reply.error(libc::EINVAL); // Not a symlink
815            }
816            None => {
817                reply.error(libc::ENOENT);
818            }
819        }
820    }
821}
822
823// =============================================================================
824// MOUNT FUNCTIONS
825// =============================================================================
826
827/// Mount options for EngramFS
828#[cfg(feature = "fuse")]
829#[derive(Clone, Debug)]
830pub struct MountOptions {
831    /// Read-only mount (default: true)
832    pub read_only: bool,
833    /// Allow other users to access the mount (default: false)
834    pub allow_other: bool,
835    /// Allow root to access the mount (default: true)
836    pub allow_root: bool,
837    /// Filesystem name shown in mount output
838    pub fsname: String,
839}
840
841#[cfg(feature = "fuse")]
842impl Default for MountOptions {
843    fn default() -> Self {
844        MountOptions {
845            read_only: true,
846            allow_other: false,
847            allow_root: true,
848            fsname: "engram".to_string(),
849        }
850    }
851}
852
853/// Mount an EngramFS at the specified path
854///
855/// This function blocks until the filesystem is unmounted. Use `spawn_mount`
856/// for a non-blocking version.
857///
858/// # Arguments
859///
860/// * `fs` - The EngramFS instance to mount
861/// * `mountpoint` - Directory path where the filesystem will be mounted
862/// * `options` - Mount options (see `MountOptions`)
863///
864/// # Example
865///
866/// ```no_run
867/// use embeddenator_fs::fuse_shim::{EngramFS, mount, MountOptions};
868///
869/// let fs = EngramFS::new(true);
870/// // ... populate fs with files ...
871///
872/// mount(fs, "/mnt/engram", MountOptions::default()).unwrap();
873/// ```
874#[cfg(feature = "fuse")]
875pub fn mount<P: AsRef<Path>>(
876    fs: EngramFS,
877    mountpoint: P,
878    options: MountOptions,
879) -> Result<(), std::io::Error> {
880    use fuser::MountOption;
881
882    let mut mount_options = vec![
883        MountOption::FSName(options.fsname),
884        MountOption::AutoUnmount,
885        MountOption::DefaultPermissions,
886    ];
887
888    if options.read_only {
889        mount_options.push(MountOption::RO);
890    }
891
892    if options.allow_other {
893        mount_options.push(MountOption::AllowOther);
894    } else if options.allow_root {
895        mount_options.push(MountOption::AllowRoot);
896    }
897
898    fuser::mount2(fs, mountpoint.as_ref(), &mount_options)
899}
900
901/// Spawn an EngramFS mount in a background thread
902///
903/// Returns a `BackgroundSession` that will automatically unmount when dropped.
904///
905/// # Arguments
906///
907/// * `fs` - The EngramFS instance to mount
908/// * `mountpoint` - Directory path where the filesystem will be mounted
909/// * `options` - Mount options (see `MountOptions`)
910///
911/// # Example
912///
913/// ```no_run
914/// use embeddenator_fs::fuse_shim::{EngramFS, spawn_mount, MountOptions};
915///
916/// let fs = EngramFS::new(true);
917/// // ... populate fs with files ...
918///
919/// let session = spawn_mount(fs, "/mnt/engram", MountOptions::default()).unwrap();
920/// // Filesystem is now mounted and accessible
921///
922/// // When session is dropped, the filesystem will be unmounted
923/// ```
924#[cfg(feature = "fuse")]
925pub fn spawn_mount<P: AsRef<Path>>(
926    fs: EngramFS,
927    mountpoint: P,
928    options: MountOptions,
929) -> Result<fuser::BackgroundSession, std::io::Error> {
930    use fuser::MountOption;
931
932    let mut mount_options = vec![
933        MountOption::FSName(options.fsname),
934        MountOption::AutoUnmount,
935        MountOption::DefaultPermissions,
936    ];
937
938    if options.read_only {
939        mount_options.push(MountOption::RO);
940    }
941
942    if options.allow_other {
943        mount_options.push(MountOption::AllowOther);
944    } else if options.allow_root {
945        mount_options.push(MountOption::AllowRoot);
946    }
947
948    fuser::spawn_mount2(fs, mountpoint.as_ref(), &mount_options)
949}
950
951// =============================================================================
952// BUILDER PATTERN
953// =============================================================================
954
955/// Builder for creating an EngramFS from engram data
956///
957/// # Example
958///
959/// ```
960/// use embeddenator_fs::fuse_shim::EngramFSBuilder;
961///
962/// let fs = EngramFSBuilder::new()
963///     .add_file("/README.md", b"# Hello World".to_vec())
964///     .add_file("/src/main.rs", b"fn main() {}".to_vec())
965///     .build();
966///
967/// assert_eq!(fs.file_count(), 2);
968/// ```
969pub struct EngramFSBuilder {
970    fs: EngramFS,
971}
972
973impl EngramFSBuilder {
974    /// Create a new builder
975    pub fn new() -> Self {
976        EngramFSBuilder {
977            fs: EngramFS::new(true), // Read-only by default
978        }
979    }
980
981    /// Add a file from decoded engram data
982    pub fn add_file(self, path: &str, data: Vec<u8>) -> Self {
983        let _ = self.fs.add_file(path, data);
984        self
985    }
986
987    /// Set read-only mode (default: true)
988    pub fn read_only(mut self, read_only: bool) -> Self {
989        self.fs.read_only = read_only;
990        self
991    }
992
993    /// Build the filesystem
994    pub fn build(self) -> EngramFS {
995        self.fs
996    }
997}
998
999impl Default for EngramFSBuilder {
1000    fn default() -> Self {
1001        Self::new()
1002    }
1003}
1004
1005// =============================================================================
1006// UTILITY FUNCTIONS
1007// =============================================================================
1008
1009/// Normalize a path (ensure leading /, remove trailing /)
1010fn normalize_path(path: &str) -> String {
1011    let path = if path.starts_with('/') {
1012        path.to_string()
1013    } else {
1014        format!("/{}", path)
1015    };
1016
1017    if path.len() > 1 && path.ends_with('/') {
1018        path[..path.len() - 1].to_string()
1019    } else {
1020        path
1021    }
1022}
1023
1024/// Get parent path
1025fn parent_path(path: &str) -> Option<String> {
1026    let path = normalize_path(path);
1027    if path == "/" {
1028        return None;
1029    }
1030
1031    match path.rfind('/') {
1032        Some(0) => Some("/".to_string()),
1033        Some(pos) => Some(path[..pos].to_string()),
1034        None => None,
1035    }
1036}
1037
1038/// Get filename from path
1039fn filename(path: &str) -> Option<&str> {
1040    let path = path.trim_end_matches('/');
1041    path.rsplit('/').next()
1042}
1043
1044/// Convert SystemTime to Duration since UNIX_EPOCH (useful for logging)
1045#[allow(dead_code)]
1046fn system_time_to_unix(time: SystemTime) -> u64 {
1047    time.duration_since(UNIX_EPOCH)
1048        .map(|d| d.as_secs())
1049        .unwrap_or(0)
1050}
1051
1052// =============================================================================
1053// STATISTICS
1054// =============================================================================
1055
1056/// Statistics for the mounted filesystem
1057#[derive(Clone, Debug, Default)]
1058pub struct MountStats {
1059    /// Number of read operations
1060    pub reads: u64,
1061    /// Total bytes read
1062    pub read_bytes: u64,
1063    /// Number of lookup operations
1064    pub lookups: u64,
1065    /// Number of readdir operations
1066    pub readdirs: u64,
1067    /// Number of cache hits
1068    pub cache_hits: u64,
1069    /// Number of cache misses
1070    pub cache_misses: u64,
1071    /// Total decode time in microseconds
1072    pub decode_time_us: u64,
1073}
1074
1075// =============================================================================
1076// TESTS
1077// =============================================================================
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::*;
1082
1083    #[test]
1084    fn test_normalize_path() {
1085        assert_eq!(normalize_path("foo"), "/foo");
1086        assert_eq!(normalize_path("/foo"), "/foo");
1087        assert_eq!(normalize_path("/foo/"), "/foo");
1088        assert_eq!(normalize_path("/"), "/");
1089    }
1090
1091    #[test]
1092    fn test_parent_path() {
1093        assert_eq!(parent_path("/foo/bar"), Some("/foo".to_string()));
1094        assert_eq!(parent_path("/foo"), Some("/".to_string()));
1095        assert_eq!(parent_path("/"), None);
1096    }
1097
1098    #[test]
1099    fn test_filename() {
1100        assert_eq!(filename("/foo/bar"), Some("bar"));
1101        assert_eq!(filename("/foo"), Some("foo"));
1102        assert_eq!(filename("/foo/bar/"), Some("bar"));
1103    }
1104
1105    #[test]
1106    fn test_add_file() {
1107        let fs = EngramFS::new(true);
1108
1109        let ino = fs.add_file("/test.txt", b"hello world".to_vec()).unwrap();
1110        assert!(ino > ROOT_INO);
1111
1112        let data = fs.read_data(ino, 0, 100).unwrap();
1113        assert_eq!(data, b"hello world");
1114    }
1115
1116    #[test]
1117    fn test_nested_directories() {
1118        let fs = EngramFS::new(true);
1119
1120        fs.add_file("/a/b/c/file.txt", b"deep".to_vec()).unwrap();
1121
1122        // All directories should exist
1123        assert!(fs.lookup_path("/a").is_some());
1124        assert!(fs.lookup_path("/a/b").is_some());
1125        assert!(fs.lookup_path("/a/b/c").is_some());
1126        assert!(fs.lookup_path("/a/b/c/file.txt").is_some());
1127    }
1128
1129    #[test]
1130    fn test_readdir() {
1131        let fs = EngramFS::new(true);
1132
1133        fs.add_file("/foo.txt", b"foo".to_vec()).unwrap();
1134        fs.add_file("/bar.txt", b"bar".to_vec()).unwrap();
1135        fs.add_file("/subdir/baz.txt", b"baz".to_vec()).unwrap();
1136
1137        let root_entries = fs.read_dir(ROOT_INO).unwrap();
1138        assert_eq!(root_entries.len(), 3); // foo.txt, bar.txt, subdir
1139
1140        let names: Vec<_> = root_entries.iter().map(|e| e.name.as_str()).collect();
1141        assert!(names.contains(&"foo.txt"));
1142        assert!(names.contains(&"bar.txt"));
1143        assert!(names.contains(&"subdir"));
1144    }
1145
1146    #[test]
1147    fn test_read_partial() {
1148        let fs = EngramFS::new(true);
1149        let data = b"0123456789";
1150
1151        let ino = fs.add_file("/test.txt", data.to_vec()).unwrap();
1152
1153        // Read middle portion
1154        let partial = fs.read_data(ino, 3, 4).unwrap();
1155        assert_eq!(partial, b"3456");
1156
1157        // Read past end
1158        let past_end = fs.read_data(ino, 20, 10).unwrap();
1159        assert!(past_end.is_empty());
1160    }
1161
1162    #[test]
1163    fn test_builder() {
1164        let fs = EngramFSBuilder::new()
1165            .add_file("/a.txt", b"a".to_vec())
1166            .add_file("/b.txt", b"b".to_vec())
1167            .build();
1168
1169        assert_eq!(fs.file_count(), 2);
1170    }
1171
1172    #[test]
1173    fn test_get_parent() {
1174        let fs = EngramFS::new(true);
1175
1176        fs.add_file("/a/b/c.txt", b"test".to_vec()).unwrap();
1177
1178        let c_ino = fs.lookup_path("/a/b/c.txt").unwrap();
1179        let b_ino = fs.lookup_path("/a/b").unwrap();
1180        let a_ino = fs.lookup_path("/a").unwrap();
1181
1182        assert_eq!(fs.get_parent(c_ino), Some(b_ino));
1183        assert_eq!(fs.get_parent(b_ino), Some(a_ino));
1184        assert_eq!(fs.get_parent(a_ino), Some(ROOT_INO));
1185        assert_eq!(fs.get_parent(ROOT_INO), Some(ROOT_INO));
1186    }
1187
1188    #[test]
1189    fn test_default_attrs() {
1190        let attr = FileAttr::default();
1191        assert_eq!(attr.perm, 0o644);
1192        assert_eq!(attr.nlink, 1);
1193        assert_eq!(attr.blksize, 4096);
1194    }
1195
1196    #[test]
1197    fn test_file_kind_conversion() {
1198        // Only run conversion tests when fuse feature is enabled
1199        #[cfg(feature = "fuse")]
1200        {
1201            let dir: fuser::FileType = FileKind::Directory.into();
1202            assert_eq!(dir, fuser::FileType::Directory);
1203
1204            let file: fuser::FileType = FileKind::RegularFile.into();
1205            assert_eq!(file, fuser::FileType::RegularFile);
1206        }
1207    }
1208
1209    #[test]
1210    fn test_lock_poisoning_recovery() {
1211        use std::sync::Arc;
1212        use std::thread;
1213
1214        // This test demonstrates that the filesystem can recover from poisoned locks
1215        // In the read path (read-only operations), we use unwrap_or_else with into_inner()
1216        // to continue serving requests even with poisoned locks
1217
1218        let fs = Arc::new(EngramFS::new(true));
1219
1220        // Add a file successfully
1221        fs.add_file("/test.txt", b"hello".to_vec()).unwrap();
1222        let ino = fs.lookup_path("/test.txt").unwrap();
1223
1224        // Simulate lock poisoning scenario by creating a poisoned lock in a thread
1225        // Note: We can't actually poison the lock in a real test without unsafe code,
1226        // but we can verify that our error handling works correctly
1227
1228        // Test that read operations continue to work
1229        let data = fs.read_data(ino, 0, 5);
1230        assert!(data.is_some());
1231        assert_eq!(data.unwrap(), b"hello");
1232
1233        // Test that lookup continues to work
1234        let found_ino = fs.lookup_path("/test.txt");
1235        assert_eq!(found_ino, Some(ino));
1236
1237        // Test that get_attr works
1238        let attr = fs.get_attr(ino);
1239        assert!(attr.is_some());
1240        assert_eq!(attr.unwrap().size, 5);
1241
1242        // Test concurrent access doesn't cause issues
1243        let fs_clone = Arc::clone(&fs);
1244        let handle = thread::spawn(move || {
1245            // Multiple reads from another thread
1246            for _ in 0..10 {
1247                let _ = fs_clone.read_data(ino, 0, 5);
1248                let _ = fs_clone.lookup_path("/test.txt");
1249            }
1250        });
1251
1252        // Simultaneous reads from main thread
1253        for _ in 0..10 {
1254            let _ = fs.read_data(ino, 0, 5);
1255            let _ = fs.get_attr(ino);
1256        }
1257
1258        handle.join().unwrap();
1259
1260        // Verify filesystem is still functional
1261        assert_eq!(fs.file_count(), 1);
1262        assert_eq!(fs.total_size(), 5);
1263    }
1264
1265    #[test]
1266    fn test_write_lock_error_propagation() {
1267        // Test that write operations properly propagate lock errors
1268        let fs = EngramFS::new(false);
1269
1270        // This should succeed
1271        let result = fs.add_file("/test.txt", b"content".to_vec());
1272        assert!(result.is_ok());
1273
1274        // Verify the file was created
1275        assert!(fs.lookup_path("/test.txt").is_some());
1276        assert_eq!(fs.file_count(), 1);
1277    }
1278}