Skip to main content

forensic_mount/
fusefs.rs

1#![forbid(unsafe_code)]
2
3use crate::inode_map::{
4    decode_fuse_ino, deleted_ino, journal_ino, metadata_ino, ro_ino, rw_ino, unallocated_ino,
5    InodeNamespace, FUSE_DELETED_INO, FUSE_JOURNAL_INO, FUSE_METADATA_INO, FUSE_ROOT_INO,
6    FUSE_RO_INO, FUSE_RW_INO, FUSE_SESSION_INO, FUSE_UNALLOCATED_INO,
7};
8use crate::session::Session;
9use crate::types::{FsBlockRange, FsEventType, FsFileType, FsMetadata, FsTimestamp};
10use crate::ForensicFs;
11use fuser::{
12    FileAttr, FileType, Filesystem, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty,
13    ReplyEntry, ReplyWrite, Request, TimeOrNow,
14};
15use std::cell::RefCell;
16use std::ffi::OsStr;
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18
19const TTL: Duration = Duration::from_secs(1);
20
21/// Virtual directory names at the FUSE root.
22const VIRTUAL_DIRS: &[(u64, &str)] = &[
23    (FUSE_RO_INO, "ro"),
24    (FUSE_RW_INO, "rw"),
25    (FUSE_DELETED_INO, "deleted"),
26    (FUSE_JOURNAL_INO, "journal"),
27    (FUSE_METADATA_INO, "metadata"),
28    (FUSE_UNALLOCATED_INO, "unallocated"),
29    (FUSE_SESSION_INO, "session"),
30];
31
32/// Cached entry for a deleted file visible in the `deleted/` virtual directory.
33struct DeletedEntry {
34    fs_ino: u64,
35    name: String,
36    size: u64,
37    data: Vec<u8>,
38}
39
40/// Cached entry for a journal transaction visible in the `journal/` virtual directory.
41struct JournalTxnEntry {
42    sequence: u64,
43    name: String,
44}
45
46/// Cached metadata files for the `metadata/` virtual directory.
47struct MetadataCache {
48    superblock_json: Vec<u8>,
49    timeline_jsonl: Vec<u8>,
50}
51
52/// Cached entry for an unallocated block range visible in the `unallocated/` virtual directory.
53struct UnallocatedEntry {
54    #[allow(dead_code)]
55    range_id: u64,
56    name: String,
57    start: u64,
58    length: u64,
59}
60
61pub struct ForensicFuseFs {
62    fs: RefCell<Box<dyn ForensicFs + Send>>,
63    session: RefCell<Option<Session>>,
64    /// Counter for allocating new overlay inode numbers (for created files).
65    overlay_ino_counter: RefCell<u64>,
66    /// The root inode number reported by the underlying filesystem.
67    root_ino: u64,
68    /// Lazy-loaded cache for the deleted/ virtual directory.
69    deleted_cache: RefCell<Option<Vec<DeletedEntry>>>,
70    /// Lazy-loaded cache for the journal/ virtual directory.
71    journal_cache: RefCell<Option<Vec<JournalTxnEntry>>>,
72    /// Lazy-loaded cache for the metadata/ virtual directory.
73    metadata_cache: RefCell<Option<MetadataCache>>,
74    /// Lazy-loaded cache for the unallocated/ virtual directory.
75    unallocated_cache: RefCell<Option<Vec<UnallocatedEntry>>>,
76    /// How the root is rendered: disk overlay (`ro/ rw/ …`) or raw tree.
77    layout: crate::MountLayout,
78}
79
80impl ForensicFuseFs {
81    pub fn new(
82        fs: Box<dyn ForensicFs + Send>,
83        session: Option<Session>,
84        layout: crate::MountLayout,
85    ) -> Self {
86        let root_ino = fs.root_ino();
87        Self {
88            fs: RefCell::new(fs),
89            session: RefCell::new(session),
90            overlay_ino_counter: RefCell::new(1),
91            root_ino,
92            deleted_cache: RefCell::new(None),
93            journal_cache: RefCell::new(None),
94            metadata_cache: RefCell::new(None),
95            unallocated_cache: RefCell::new(None),
96            layout,
97        }
98    }
99
100    /// Check if a session is available (rw/ operations require one).
101    fn has_session(&self) -> bool {
102        self.session.borrow().is_some()
103    }
104
105    /// Get the overlay file ID for a modified inode.
106    fn modified_overlay_id(fs_ino: u64) -> String {
107        format!("ino_{fs_ino}")
108    }
109
110    /// Allocate a new overlay inode number for created files.
111    fn alloc_overlay_ino(&self) -> u64 {
112        let mut counter = self.overlay_ino_counter.borrow_mut();
113        let ino = *counter;
114        *counter += 1;
115        ino
116    }
117
118    /// Get the overlay file ID for a newly created file.
119    fn created_overlay_id(counter: u64) -> String {
120        format!("new_{counter}")
121    }
122
123    /// Build a `FileAttr` for an overlay-created file.
124    fn overlay_created_attr(fuse_ino: u64, size: u64, is_dir: bool) -> FileAttr {
125        let kind = if is_dir {
126            FileType::Directory
127        } else {
128            FileType::RegularFile
129        };
130        FileAttr {
131            ino: fuse_ino,
132            size,
133            blocks: size.div_ceil(512),
134            atime: SystemTime::now(),
135            mtime: SystemTime::now(),
136            ctime: SystemTime::now(),
137            crtime: SystemTime::now(),
138            kind,
139            perm: if is_dir { 0o755 } else { 0o644 },
140            nlink: 1,
141            uid: 0,
142            gid: 0,
143            rdev: 0,
144            blksize: 4096,
145            flags: 0,
146        }
147    }
148
149    /// Resolve rw/ parent inode to underlying fs parent inode.
150    fn rw_parent_to_fs(&self, parent: u64) -> Option<u64> {
151        match parent {
152            FUSE_RW_INO => Some(self.root_ino),
153            _ => match decode_fuse_ino(parent) {
154                InodeNamespace::Rw(ino) => Some(ino),
155                _ => None,
156            },
157        }
158    }
159
160    /// Check if an inode is in the whiteout (deleted) list.
161    fn is_whiteout(&self, fs_ino: u64) -> bool {
162        let session = self.session.borrow();
163        match session.as_ref() {
164            Some(s) => s.overlay.deleted.contains(&fs_ino),
165            None => false,
166        }
167    }
168
169    /// Ensure the deleted/ cache is populated.
170    fn ensure_deleted_cache(&self) {
171        if self.deleted_cache.borrow().is_some() {
172            return;
173        }
174        let mut fs = self.fs.borrow_mut();
175        let deleted_inodes = fs.deleted_inodes().unwrap_or_default();
176        let mut entries = Vec::new();
177        for di in &deleted_inodes {
178            let name = format!("{}_unknown", di.ino);
179            let result = fs.recover_file(di.ino);
180            let (size, data) = match result {
181                Ok(r) => (r.data.len() as u64, r.data),
182                Err(_) => (0, Vec::new()),
183            };
184            entries.push(DeletedEntry {
185                fs_ino: di.ino,
186                name,
187                size,
188                data,
189            });
190        }
191        *self.deleted_cache.borrow_mut() = Some(entries);
192    }
193
194    /// Ensure the journal/ cache is populated.
195    fn ensure_journal_cache(&self) {
196        if self.journal_cache.borrow().is_some() {
197            return;
198        }
199        let mut fs = self.fs.borrow_mut();
200        let entries = match fs.journal_transactions() {
201            Ok(txns) => txns
202                .iter()
203                .map(|txn| JournalTxnEntry {
204                    sequence: txn.sequence,
205                    name: format!("txn_{}", txn.sequence),
206                })
207                .collect(),
208            Err(_) => Vec::new(),
209        };
210        *self.journal_cache.borrow_mut() = Some(entries);
211    }
212
213    /// Ensure the metadata/ cache is populated.
214    fn ensure_metadata_cache(&self) {
215        if self.metadata_cache.borrow().is_some() {
216            return;
217        }
218        let fs = self.fs.borrow();
219
220        // Build superblock.json from fs_info()
221        let superblock_json = match fs.fs_info() {
222            Ok(info) => serde_json::to_string_pretty(&info)
223                .unwrap_or_default()
224                .into_bytes(),
225            Err(_) => b"{}".to_vec(),
226        };
227        drop(fs);
228
229        // Build timeline.jsonl
230        let mut fs = self.fs.borrow_mut();
231        let timeline_jsonl = match fs.timeline() {
232            Ok(events) => {
233                let mut buf = Vec::new();
234                for event in &events {
235                    let event_type = match event.event_type {
236                        FsEventType::Created => "Created",
237                        FsEventType::Modified => "Modified",
238                        FsEventType::Accessed => "Accessed",
239                        FsEventType::Changed => "Changed",
240                        FsEventType::Deleted => "Deleted",
241                        FsEventType::Mounted => "Mounted",
242                    };
243                    let line = serde_json::json!({
244                        "timestamp_secs": event.timestamp.seconds,
245                        "timestamp_nsecs": event.timestamp.nanoseconds,
246                        "event_type": event_type,
247                        "inode": event.inode,
248                        "size": event.size,
249                        "uid": event.uid,
250                        "gid": event.gid,
251                    });
252                    let line_str = serde_json::to_string(&line).unwrap_or_default();
253                    buf.extend_from_slice(line_str.as_bytes());
254                    buf.push(b'\n');
255                }
256                buf
257            }
258            Err(_) => Vec::new(),
259        };
260
261        *self.metadata_cache.borrow_mut() = Some(MetadataCache {
262            superblock_json,
263            timeline_jsonl,
264        });
265    }
266
267    /// Ensure the unallocated/ cache is populated.
268    fn ensure_unallocated_cache(&self) {
269        if self.unallocated_cache.borrow().is_some() {
270            return;
271        }
272        let mut fs = self.fs.borrow_mut();
273        let entries = match fs.unallocated_blocks() {
274            Ok(ranges) => ranges
275                .iter()
276                .enumerate()
277                .map(|(i, r)| UnallocatedEntry {
278                    range_id: i as u64,
279                    name: format!("blocks_{}-{}.raw", r.start, r.start + r.length),
280                    start: r.start,
281                    length: r.length,
282                })
283                .collect(),
284            Err(_) => Vec::new(),
285        };
286        *self.unallocated_cache.borrow_mut() = Some(entries);
287    }
288
289    /// Find a created overlay entry by `parent_ino` and name.
290    fn find_created_by_name(&self, parent_ino: u64, name: &[u8]) -> Option<(String, u64, bool)> {
291        let session = self.session.borrow();
292        let session = session.as_ref()?;
293        let name_str = std::str::from_utf8(name).ok()?;
294        for (id, entry) in &session.overlay.created {
295            if entry.parent_ino == parent_ino && entry.name == name_str {
296                let counter: u64 = id.strip_prefix("new_").and_then(|s| s.parse().ok())?;
297                return Some((id.clone(), counter, false));
298            }
299        }
300        for (id, entry) in &session.overlay.dirs {
301            if entry.parent_ino == parent_ino && entry.name == name_str {
302                let counter: u64 = id.strip_prefix("new_").and_then(|s| s.parse().ok())?;
303                return Some((id.clone(), counter, true));
304            }
305        }
306        None
307    }
308}
309
310/// Convert a `FsTimestamp` to `SystemTime`.
311fn ts_to_systime(t: &FsTimestamp) -> SystemTime {
312    if t.seconds >= 0 {
313        UNIX_EPOCH + Duration::new(t.seconds as u64, t.nanoseconds)
314    } else {
315        UNIX_EPOCH
316    }
317}
318
319/// Build a `FileAttr` from an `FsMetadata`.
320fn fs_to_attr(fuse_ino: u64, meta: &FsMetadata) -> FileAttr {
321    let kind = match meta.file_type {
322        FsFileType::RegularFile | FsFileType::Unknown => FileType::RegularFile,
323        FsFileType::Directory => FileType::Directory,
324        FsFileType::Symlink => FileType::Symlink,
325        FsFileType::CharDevice => FileType::CharDevice,
326        FsFileType::BlockDevice => FileType::BlockDevice,
327        FsFileType::Fifo => FileType::NamedPipe,
328        FsFileType::Socket => FileType::Socket,
329    };
330
331    FileAttr {
332        ino: fuse_ino,
333        size: meta.size,
334        blocks: meta.size.div_ceil(512),
335        atime: ts_to_systime(&meta.atime),
336        mtime: ts_to_systime(&meta.mtime),
337        ctime: ts_to_systime(&meta.ctime),
338        crtime: ts_to_systime(&meta.crtime),
339        kind,
340        perm: meta.mode & 0o7777,
341        nlink: u32::from(meta.links_count),
342        uid: meta.uid,
343        gid: meta.gid,
344        rdev: 0,
345        blksize: 4096,
346        flags: 0,
347    }
348}
349
350/// Build a synthetic `FileAttr` for a virtual directory.
351fn virtual_dir_attr(ino: u64) -> FileAttr {
352    FileAttr {
353        ino,
354        size: 0,
355        blocks: 0,
356        atime: UNIX_EPOCH,
357        mtime: UNIX_EPOCH,
358        ctime: UNIX_EPOCH,
359        crtime: UNIX_EPOCH,
360        kind: FileType::Directory,
361        perm: 0o555,
362        nlink: 2,
363        uid: 0,
364        gid: 0,
365        rdev: 0,
366        blksize: 4096,
367        flags: 0,
368    }
369}
370
371/// Build a synthetic `FileAttr` for a virtual read-only regular file.
372fn virtual_file_attr(ino: u64, size: u64) -> FileAttr {
373    FileAttr {
374        ino,
375        size,
376        blocks: size.div_ceil(512),
377        atime: UNIX_EPOCH,
378        mtime: UNIX_EPOCH,
379        ctime: UNIX_EPOCH,
380        crtime: UNIX_EPOCH,
381        kind: FileType::RegularFile,
382        perm: 0o444,
383        nlink: 1,
384        uid: 0,
385        gid: 0,
386        rdev: 0,
387        blksize: 4096,
388        flags: 0,
389    }
390}
391
392/// Convert an `FsFileType` to a fuser `FileType`.
393fn fs_file_type_to_fuse(t: FsFileType) -> FileType {
394    match t {
395        FsFileType::RegularFile | FsFileType::Unknown => FileType::RegularFile,
396        FsFileType::Directory => FileType::Directory,
397        FsFileType::Symlink => FileType::Symlink,
398        FsFileType::CharDevice => FileType::CharDevice,
399        FsFileType::BlockDevice => FileType::BlockDevice,
400        FsFileType::Fifo => FileType::NamedPipe,
401        FsFileType::Socket => FileType::Socket,
402    }
403}
404
405/// The entries shown at the FUSE mount root, per [`MountLayout`].
406///
407/// `DiskOverlay` lists the virtual directories (`ro/`, `rw/`, `deleted/`, …);
408/// `Raw` lists the underlying [`ForensicFs`] root's children directly (encoded
409/// into the `ro/` inode namespace so the existing sub-tree callbacks serve
410/// them), with no overlay directories. Returns `(fuse_ino, name, file_type)`;
411/// `.`/`..` are added by the caller.
412fn root_children(
413    layout: crate::MountLayout,
414    fs: &mut dyn ForensicFs,
415    root_ino: u64,
416) -> crate::FsResult<Vec<(u64, Vec<u8>, FileType)>> {
417    match layout {
418        crate::MountLayout::DiskOverlay => Ok(VIRTUAL_DIRS
419            .iter()
420            .map(|&(ino, name)| (ino, name.as_bytes().to_vec(), FileType::Directory))
421            .collect()),
422        crate::MountLayout::Raw => {
423            let mut out = Vec::new();
424            for e in fs.read_dir(root_ino)? {
425                if e.name == b"." || e.name == b".." {
426                    continue;
427                }
428                // Encode into the ro/ namespace so the existing sub-tree
429                // callbacks (Ro decode) serve everything below the root.
430                out.push((ro_ino(e.inode), e.name, fs_file_type_to_fuse(e.file_type)));
431            }
432            Ok(out)
433        }
434    }
435}
436
437impl Filesystem for ForensicFuseFs {
438    fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
439        let name_bytes = name.as_encoded_bytes();
440
441        // Virtual root (disk overlay): resolve virtual directory names. In Raw
442        // layout the root maps straight to the ForensicFs root (handled below).
443        if parent == FUSE_ROOT_INO && self.layout == crate::MountLayout::DiskOverlay {
444            for &(ino, dir_name) in VIRTUAL_DIRS {
445                if name_bytes == dir_name.as_bytes() {
446                    let attr = if ino == FUSE_RW_INO && self.has_session() {
447                        let mut a = virtual_dir_attr(ino);
448                        a.perm = 0o755;
449                        a
450                    } else {
451                        virtual_dir_attr(ino)
452                    };
453                    reply.entry(&TTL, &attr, 0);
454                    return;
455                }
456            }
457            reply.error(libc::ENOENT);
458            return;
459        }
460
461        // deleted/ namespace lookup.
462        if parent == FUSE_DELETED_INO {
463            self.ensure_deleted_cache();
464            let cache = self.deleted_cache.borrow();
465            if let Some(entries) = cache.as_ref() {
466                for entry in entries {
467                    if name_bytes == entry.name.as_bytes() {
468                        let fuse_ino = deleted_ino(entry.fs_ino);
469                        let attr = virtual_file_attr(fuse_ino, entry.size);
470                        reply.entry(&TTL, &attr, 0);
471                        return;
472                    }
473                }
474            }
475            reply.error(libc::ENOENT);
476            return;
477        }
478
479        // journal/ namespace lookup.
480        if parent == FUSE_JOURNAL_INO {
481            self.ensure_journal_cache();
482            let cache = self.journal_cache.borrow();
483            if let Some(entries) = cache.as_ref() {
484                for entry in entries {
485                    if name_bytes == entry.name.as_bytes() {
486                        let fuse_ino = journal_ino(entry.sequence);
487                        let attr = virtual_dir_attr(fuse_ino);
488                        reply.entry(&TTL, &attr, 0);
489                        return;
490                    }
491                }
492            }
493            reply.error(libc::ENOENT);
494            return;
495        }
496
497        // metadata/ namespace lookup.
498        if parent == FUSE_METADATA_INO {
499            self.ensure_metadata_cache();
500            let cache = self.metadata_cache.borrow();
501            if let Some(mc) = cache.as_ref() {
502                if name_bytes == b"superblock.json" {
503                    let fuse_ino = metadata_ino(1);
504                    let attr = virtual_file_attr(fuse_ino, mc.superblock_json.len() as u64);
505                    reply.entry(&TTL, &attr, 0);
506                    return;
507                }
508                if name_bytes == b"timeline.jsonl" {
509                    let fuse_ino = metadata_ino(2);
510                    let attr = virtual_file_attr(fuse_ino, mc.timeline_jsonl.len() as u64);
511                    reply.entry(&TTL, &attr, 0);
512                    return;
513                }
514            }
515            reply.error(libc::ENOENT);
516            return;
517        }
518
519        // unallocated/ namespace lookup.
520        if parent == FUSE_UNALLOCATED_INO {
521            self.ensure_unallocated_cache();
522            let cache = self.unallocated_cache.borrow();
523            if let Some(entries) = cache.as_ref() {
524                for (i, entry) in entries.iter().enumerate() {
525                    if name_bytes == entry.name.as_bytes() {
526                        let fuse_ino = unallocated_ino(i as u64);
527                        let block_size = self.fs.borrow().block_size();
528                        let size = entry.length * block_size;
529                        let attr = virtual_file_attr(fuse_ino, size);
530                        reply.entry(&TTL, &attr, 0);
531                        return;
532                    }
533                }
534            }
535            reply.error(libc::ENOENT);
536            return;
537        }
538
539        // session/ namespace lookup.
540        if parent == FUSE_SESSION_INO {
541            if name_bytes == b"status.json" && self.has_session() {
542                let session = self.session.borrow();
543                let s = session.as_ref().unwrap();
544                let status = serde_json::json!({
545                    "image_path": s.metadata.image_path,
546                    "image_sha256": s.metadata.image_sha256,
547                    "created": s.metadata.created,
548                });
549                let data = serde_json::to_string_pretty(&status)
550                    .unwrap_or_default()
551                    .into_bytes();
552                let fuse_ino = metadata_ino(100);
553                let attr = virtual_file_attr(fuse_ino, data.len() as u64);
554                reply.entry(&TTL, &attr, 0);
555                return;
556            }
557            reply.error(libc::ENOENT);
558            return;
559        }
560
561        // rw/ namespace lookup.
562        if let Some(fs_parent) = self.rw_parent_to_fs(parent) {
563            // Check overlay created files first.
564            if let Some((id, counter, is_dir)) = self.find_created_by_name(fs_parent, name_bytes) {
565                let session = self.session.borrow();
566                let session = session.as_ref().unwrap();
567                let entry = if is_dir {
568                    session.overlay.dirs.get(&id)
569                } else {
570                    session.overlay.created.get(&id)
571                };
572                if let Some(entry) = entry {
573                    let fuse_ino = rw_ino(counter + 9_000_000);
574                    let attr = Self::overlay_created_attr(fuse_ino, entry.size, is_dir);
575                    reply.entry(&TTL, &attr, 0);
576                    return;
577                }
578            }
579
580            // Check if name is a modified file.
581            {
582                let mut fs = self.fs.borrow_mut();
583                match fs.lookup(fs_parent, name_bytes) {
584                    Ok(Some(child_ino)) => {
585                        // Check whiteout.
586                        if self.is_whiteout(child_ino) {
587                            reply.error(libc::ENOENT);
588                            return;
589                        }
590
591                        // Check if modified in overlay.
592                        let session = self.session.borrow();
593                        let overlay_id = Self::modified_overlay_id(child_ino);
594                        if let Some(s) = session.as_ref() {
595                            if s.overlay.modified.contains_key(&child_ino) {
596                                if let Ok(meta) = fs.metadata(child_ino) {
597                                    let fuse_ino = rw_ino(child_ino);
598                                    let mut attr = fs_to_attr(fuse_ino, &meta);
599                                    if let Ok(data) = s.read_overlay_file(&overlay_id) {
600                                        attr.size = data.len() as u64;
601                                        attr.blocks = attr.size.div_ceil(512);
602                                    }
603                                    reply.entry(&TTL, &attr, 0);
604                                    return;
605                                }
606                                reply.error(libc::EIO);
607                                return;
608                            }
609                        }
610
611                        // Not modified, return attrs under rw/ namespace.
612                        match fs.metadata(child_ino) {
613                            Ok(meta) => {
614                                let fuse_ino = rw_ino(child_ino);
615                                reply.entry(&TTL, &fs_to_attr(fuse_ino, &meta), 0);
616                            }
617                            Err(_) => reply.error(libc::EIO),
618                        }
619                        return;
620                    }
621                    Ok(None) => {
622                        reply.error(libc::ENOENT);
623                        return;
624                    }
625                    Err(_) => {
626                        reply.error(libc::EIO);
627                        return;
628                    }
629                }
630            }
631        }
632
633        // ro/ namespace: the ro/ virtual dir maps to the fs root inode. In Raw
634        // layout the FUSE root itself maps to the fs root (no ro/ wrapper).
635        let fs_parent = match parent {
636            FUSE_RO_INO => self.root_ino,
637            FUSE_ROOT_INO if self.layout == crate::MountLayout::Raw => self.root_ino,
638            _ => {
639                if let InodeNamespace::Ro(ino) = decode_fuse_ino(parent) {
640                    ino
641                } else {
642                    reply.error(libc::ENOENT);
643                    return;
644                }
645            }
646        };
647
648        let mut fs = self.fs.borrow_mut();
649        match fs.lookup(fs_parent, name_bytes) {
650            Ok(Some(child_ino)) => match fs.metadata(child_ino) {
651                Ok(meta) => {
652                    let fuse_ino = ro_ino(child_ino);
653                    reply.entry(&TTL, &fs_to_attr(fuse_ino, &meta), 0);
654                }
655                Err(_) => reply.error(libc::EIO),
656            },
657            Ok(None) => reply.error(libc::ENOENT),
658            Err(_) => reply.error(libc::EIO),
659        }
660    }
661
662    fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
663        // Virtual root.
664        if ino == FUSE_ROOT_INO {
665            reply.attr(&TTL, &virtual_dir_attr(FUSE_ROOT_INO));
666            return;
667        }
668
669        // Virtual top-level directories.
670        if (FUSE_RO_INO..=FUSE_SESSION_INO).contains(&ino) {
671            let mut attr = virtual_dir_attr(ino);
672            if ino == FUSE_RW_INO && self.has_session() {
673                attr.perm = 0o755;
674            }
675            reply.attr(&TTL, &attr);
676            return;
677        }
678
679        match decode_fuse_ino(ino) {
680            InodeNamespace::Deleted(fs_ino) => {
681                self.ensure_deleted_cache();
682                let cache = self.deleted_cache.borrow();
683                if let Some(entries) = cache.as_ref() {
684                    if let Some(entry) = entries.iter().find(|e| e.fs_ino == fs_ino) {
685                        reply.attr(&TTL, &virtual_file_attr(ino, entry.size));
686                        return;
687                    }
688                }
689                reply.error(libc::ENOENT);
690            }
691            InodeNamespace::Metadata(id) => {
692                self.ensure_metadata_cache();
693                let cache = self.metadata_cache.borrow();
694                if let Some(mc) = cache.as_ref() {
695                    match id {
696                        1 => {
697                            reply.attr(
698                                &TTL,
699                                &virtual_file_attr(ino, mc.superblock_json.len() as u64),
700                            );
701                        }
702                        2 => {
703                            reply.attr(
704                                &TTL,
705                                &virtual_file_attr(ino, mc.timeline_jsonl.len() as u64),
706                            );
707                        }
708                        100 => {
709                            // session/status.json
710                            if self.has_session() {
711                                let session = self.session.borrow();
712                                let s = session.as_ref().unwrap();
713                                let status = serde_json::json!({
714                                    "image_path": s.metadata.image_path,
715                                    "image_sha256": s.metadata.image_sha256,
716                                    "created": s.metadata.created,
717                                });
718                                let data =
719                                    serde_json::to_string_pretty(&status).unwrap_or_default();
720                                reply.attr(&TTL, &virtual_file_attr(ino, data.len() as u64));
721                            } else {
722                                reply.error(libc::ENOENT);
723                            }
724                        }
725                        _ => reply.error(libc::ENOENT),
726                    }
727                } else {
728                    reply.error(libc::ENOENT);
729                }
730            }
731            InodeNamespace::Journal(seq) => {
732                self.ensure_journal_cache();
733                let cache = self.journal_cache.borrow();
734                if let Some(entries) = cache.as_ref() {
735                    if entries.iter().any(|e| e.sequence == seq) {
736                        reply.attr(&TTL, &virtual_dir_attr(ino));
737                    } else {
738                        reply.error(libc::ENOENT);
739                    }
740                } else {
741                    reply.error(libc::ENOENT);
742                }
743            }
744            InodeNamespace::Unallocated(range_id) => {
745                self.ensure_unallocated_cache();
746                let cache = self.unallocated_cache.borrow();
747                if let Some(entries) = cache.as_ref() {
748                    if let Some(entry) = entries.get(range_id as usize) {
749                        let block_size = self.fs.borrow().block_size();
750                        let size = entry.length * block_size;
751                        reply.attr(&TTL, &virtual_file_attr(ino, size));
752                    } else {
753                        reply.error(libc::ENOENT);
754                    }
755                } else {
756                    reply.error(libc::ENOENT);
757                }
758            }
759            InodeNamespace::Ro(fs_ino) => {
760                let mut fs = self.fs.borrow_mut();
761                match fs.metadata(fs_ino) {
762                    Ok(meta) => reply.attr(&TTL, &fs_to_attr(ino, &meta)),
763                    Err(_) => reply.error(libc::EIO),
764                }
765            }
766            InodeNamespace::Rw(rw_id) => {
767                // Check if this is a created overlay file (counter + 9_000_000).
768                if rw_id >= 9_000_000 {
769                    let counter = rw_id - 9_000_000;
770                    let created_id = Self::created_overlay_id(counter);
771                    let session = self.session.borrow();
772                    if let Some(s) = session.as_ref() {
773                        if let Some(entry) = s.overlay.created.get(&created_id) {
774                            let attr = Self::overlay_created_attr(ino, entry.size, false);
775                            reply.attr(&TTL, &attr);
776                            return;
777                        }
778                        if let Some(entry) = s.overlay.dirs.get(&created_id) {
779                            let attr = Self::overlay_created_attr(ino, entry.size, true);
780                            reply.attr(&TTL, &attr);
781                            return;
782                        }
783                    }
784                    reply.error(libc::ENOENT);
785                    return;
786                }
787
788                // This is an fs inode viewed through rw/.
789                let fs_ino = rw_id;
790                let mut fs = self.fs.borrow_mut();
791                match fs.metadata(fs_ino) {
792                    Ok(meta) => {
793                        let mut attr = fs_to_attr(ino, &meta);
794                        // If modified, update size from overlay.
795                        let session = self.session.borrow();
796                        if let Some(s) = session.as_ref() {
797                            let overlay_id = Self::modified_overlay_id(fs_ino);
798                            if s.overlay.modified.contains_key(&fs_ino) {
799                                if let Ok(data) = s.read_overlay_file(&overlay_id) {
800                                    attr.size = data.len() as u64;
801                                    attr.blocks = attr.size.div_ceil(512);
802                                }
803                            }
804                        }
805                        reply.attr(&TTL, &attr);
806                    }
807                    Err(_) => reply.error(libc::EIO),
808                }
809            }
810            _ => reply.error(libc::ENOENT),
811        }
812    }
813
814    fn readdir(
815        &mut self,
816        _req: &Request,
817        ino: u64,
818        _fh: u64,
819        offset: i64,
820        mut reply: ReplyDirectory,
821    ) {
822        let offset = offset as usize;
823
824        // Root directory: virtual dirs (DiskOverlay) or the ForensicFs tree
825        // directly (Raw) — both via the tested root_children() decision.
826        if ino == FUSE_ROOT_INO {
827            let mut entries: Vec<(u64, FileType, String)> = vec![
828                (FUSE_ROOT_INO, FileType::Directory, ".".to_string()),
829                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
830            ];
831            {
832                let mut fs = self.fs.borrow_mut();
833                let Ok(children) = root_children(self.layout, &mut **fs, self.root_ino) else {
834                    reply.error(libc::EIO);
835                    return;
836                };
837                for (fino, name, kind) in children {
838                    entries.push((fino, kind, String::from_utf8_lossy(&name).into_owned()));
839                }
840            }
841            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
842                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
843                    break;
844                }
845            }
846            reply.ok();
847            return;
848        }
849
850        // rw/ namespace readdir.
851        if let Some(fs_dir_ino) = match ino {
852            FUSE_RW_INO => Some(self.root_ino),
853            _ => match decode_fuse_ino(ino) {
854                InodeNamespace::Rw(rw_id) if rw_id < 9_000_000 => Some(rw_id),
855                _ => None,
856            },
857        } {
858            let mut fs = self.fs.borrow_mut();
859            match fs.read_dir(fs_dir_ino) {
860                Ok(entries) => {
861                    let session = self.session.borrow();
862                    let mut fuse_entries: Vec<(u64, FileType, String)> = Vec::new();
863
864                    for e in &entries {
865                        let name = e.name_str();
866                        let child_ino = e.inode;
867
868                        // Filter out whiteouts.
869                        if let Some(s) = session.as_ref() {
870                            if name != "." && name != ".." && s.overlay.deleted.contains(&child_ino)
871                            {
872                                continue;
873                            }
874                        }
875
876                        let fuse_ino = if name == "." || name == ".." {
877                            if fs_dir_ino == self.root_ino && name == "." {
878                                FUSE_RW_INO
879                            } else if fs_dir_ino == self.root_ino && name == ".." {
880                                FUSE_ROOT_INO
881                            } else {
882                                rw_ino(child_ino)
883                            }
884                        } else {
885                            rw_ino(child_ino)
886                        };
887                        let kind = fs_file_type_to_fuse(e.file_type);
888                        fuse_entries.push((fuse_ino, kind, name));
889                    }
890
891                    // Add overlay created entries for this directory.
892                    if let Some(s) = session.as_ref() {
893                        for (id, entry) in &s.overlay.created {
894                            if entry.parent_ino == fs_dir_ino {
895                                if let Some(counter) =
896                                    id.strip_prefix("new_").and_then(|s| s.parse::<u64>().ok())
897                                {
898                                    let fuse_ino = rw_ino(counter + 9_000_000);
899                                    fuse_entries.push((
900                                        fuse_ino,
901                                        FileType::RegularFile,
902                                        entry.name.clone(),
903                                    ));
904                                }
905                            }
906                        }
907                        for (id, entry) in &s.overlay.dirs {
908                            if entry.parent_ino == fs_dir_ino {
909                                if let Some(counter) =
910                                    id.strip_prefix("new_").and_then(|s| s.parse::<u64>().ok())
911                                {
912                                    let fuse_ino = rw_ino(counter + 9_000_000);
913                                    fuse_entries.push((
914                                        fuse_ino,
915                                        FileType::Directory,
916                                        entry.name.clone(),
917                                    ));
918                                }
919                            }
920                        }
921                    }
922
923                    for (i, (entry_ino, kind, name)) in fuse_entries.iter().enumerate().skip(offset)
924                    {
925                        if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
926                            break;
927                        }
928                    }
929                    reply.ok();
930                }
931                Err(_) => reply.error(libc::EIO),
932            }
933            return;
934        }
935
936        // deleted/ readdir
937        if ino == FUSE_DELETED_INO {
938            self.ensure_deleted_cache();
939            let cache = self.deleted_cache.borrow();
940            let mut entries: Vec<(u64, FileType, String)> = vec![
941                (FUSE_DELETED_INO, FileType::Directory, ".".to_string()),
942                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
943            ];
944            if let Some(cached) = cache.as_ref() {
945                for entry in cached {
946                    entries.push((
947                        deleted_ino(entry.fs_ino),
948                        FileType::RegularFile,
949                        entry.name.clone(),
950                    ));
951                }
952            }
953            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
954                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
955                    break;
956                }
957            }
958            reply.ok();
959            return;
960        }
961
962        // journal/ readdir
963        if ino == FUSE_JOURNAL_INO {
964            self.ensure_journal_cache();
965            let cache = self.journal_cache.borrow();
966            let mut entries: Vec<(u64, FileType, String)> = vec![
967                (FUSE_JOURNAL_INO, FileType::Directory, ".".to_string()),
968                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
969            ];
970            if let Some(cached) = cache.as_ref() {
971                for entry in cached {
972                    entries.push((
973                        journal_ino(entry.sequence),
974                        FileType::Directory,
975                        entry.name.clone(),
976                    ));
977                }
978            }
979            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
980                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
981                    break;
982                }
983            }
984            reply.ok();
985            return;
986        }
987
988        // journal/txn_N/ readdir (empty directory for now)
989        if let InodeNamespace::Journal(_seq) = decode_fuse_ino(ino) {
990            if offset == 0 {
991                let _ = reply.add(ino, 1, FileType::Directory, ".");
992                let _ = reply.add(FUSE_JOURNAL_INO, 2, FileType::Directory, "..");
993            }
994            reply.ok();
995            return;
996        }
997
998        // metadata/ readdir
999        if ino == FUSE_METADATA_INO {
1000            self.ensure_metadata_cache();
1001            let cache = self.metadata_cache.borrow();
1002            let mut entries: Vec<(u64, FileType, String)> = vec![
1003                (FUSE_METADATA_INO, FileType::Directory, ".".to_string()),
1004                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1005            ];
1006            if cache.is_some() {
1007                entries.push((
1008                    metadata_ino(1),
1009                    FileType::RegularFile,
1010                    "superblock.json".to_string(),
1011                ));
1012                entries.push((
1013                    metadata_ino(2),
1014                    FileType::RegularFile,
1015                    "timeline.jsonl".to_string(),
1016                ));
1017            }
1018            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1019                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1020                    break;
1021                }
1022            }
1023            reply.ok();
1024            return;
1025        }
1026
1027        // unallocated/ readdir
1028        if ino == FUSE_UNALLOCATED_INO {
1029            self.ensure_unallocated_cache();
1030            let cache = self.unallocated_cache.borrow();
1031            let mut entries: Vec<(u64, FileType, String)> = vec![
1032                (FUSE_UNALLOCATED_INO, FileType::Directory, ".".to_string()),
1033                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1034            ];
1035            if let Some(cached) = cache.as_ref() {
1036                for (i, entry) in cached.iter().enumerate() {
1037                    entries.push((
1038                        unallocated_ino(i as u64),
1039                        FileType::RegularFile,
1040                        entry.name.clone(),
1041                    ));
1042                }
1043            }
1044            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1045                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1046                    break;
1047                }
1048            }
1049            reply.ok();
1050            return;
1051        }
1052
1053        // session/ readdir
1054        if ino == FUSE_SESSION_INO {
1055            let mut entries: Vec<(u64, FileType, String)> = vec![
1056                (FUSE_SESSION_INO, FileType::Directory, ".".to_string()),
1057                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1058            ];
1059            if self.has_session() {
1060                entries.push((
1061                    metadata_ino(100),
1062                    FileType::RegularFile,
1063                    "status.json".to_string(),
1064                ));
1065            }
1066            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1067                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1068                    break;
1069                }
1070            }
1071            reply.ok();
1072            return;
1073        }
1074
1075        // Determine the fs inode for this directory.
1076        let fs_dir_ino = match ino {
1077            FUSE_RO_INO => self.root_ino,
1078            _ => {
1079                if let InodeNamespace::Ro(fs_ino) = decode_fuse_ino(ino) {
1080                    fs_ino
1081                } else {
1082                    reply.error(libc::ENOENT);
1083                    return;
1084                }
1085            }
1086        };
1087
1088        let mut fs = self.fs.borrow_mut();
1089        match fs.read_dir(fs_dir_ino) {
1090            Ok(entries) => {
1091                let root_ino = self.root_ino;
1092                let fuse_entries: Vec<(u64, FileType, String)> = entries
1093                    .iter()
1094                    .map(|e| {
1095                        let name = e.name_str();
1096                        let fuse_ino = if name == "." || name == ".." {
1097                            if fs_dir_ino == root_ino && name == "." {
1098                                FUSE_RO_INO
1099                            } else if fs_dir_ino == root_ino && name == ".." {
1100                                FUSE_ROOT_INO
1101                            } else {
1102                                ro_ino(e.inode)
1103                            }
1104                        } else {
1105                            ro_ino(e.inode)
1106                        };
1107                        let kind = fs_file_type_to_fuse(e.file_type);
1108                        (fuse_ino, kind, name)
1109                    })
1110                    .collect();
1111
1112                for (i, (entry_ino, kind, name)) in fuse_entries.iter().enumerate().skip(offset) {
1113                    if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1114                        break;
1115                    }
1116                }
1117                reply.ok();
1118            }
1119            Err(_) => reply.error(libc::EIO),
1120        }
1121    }
1122
1123    fn read(
1124        &mut self,
1125        _req: &Request,
1126        ino: u64,
1127        _fh: u64,
1128        offset: i64,
1129        size: u32,
1130        _flags: i32,
1131        _lock_owner: Option<u64>,
1132        reply: ReplyData,
1133    ) {
1134        match decode_fuse_ino(ino) {
1135            InodeNamespace::Deleted(fs_ino) => {
1136                self.ensure_deleted_cache();
1137                let cache = self.deleted_cache.borrow();
1138                if let Some(entries) = cache.as_ref() {
1139                    if let Some(entry) = entries.iter().find(|e| e.fs_ino == fs_ino) {
1140                        let off = offset as usize;
1141                        if off >= entry.data.len() {
1142                            reply.data(&[]);
1143                        } else {
1144                            let end = (off + size as usize).min(entry.data.len());
1145                            reply.data(&entry.data[off..end]);
1146                        }
1147                        return;
1148                    }
1149                }
1150                reply.error(libc::ENOENT);
1151            }
1152            InodeNamespace::Metadata(id) => {
1153                self.ensure_metadata_cache();
1154                let data = match id {
1155                    1 => {
1156                        let cache = self.metadata_cache.borrow();
1157                        cache.as_ref().map(|mc| mc.superblock_json.clone())
1158                    }
1159                    2 => {
1160                        let cache = self.metadata_cache.borrow();
1161                        cache.as_ref().map(|mc| mc.timeline_jsonl.clone())
1162                    }
1163                    100 => {
1164                        // session/status.json
1165                        let session = self.session.borrow();
1166                        session.as_ref().map(|s| {
1167                            let status = serde_json::json!({
1168                                "image_path": s.metadata.image_path,
1169                                "image_sha256": s.metadata.image_sha256,
1170                                "created": s.metadata.created,
1171                            });
1172                            serde_json::to_string_pretty(&status)
1173                                .unwrap_or_default()
1174                                .into_bytes()
1175                        })
1176                    }
1177                    _ => None,
1178                };
1179                match data {
1180                    Some(buf) => {
1181                        let off = offset as usize;
1182                        if off >= buf.len() {
1183                            reply.data(&[]);
1184                        } else {
1185                            let end = (off + size as usize).min(buf.len());
1186                            reply.data(&buf[off..end]);
1187                        }
1188                    }
1189                    None => reply.error(libc::ENOENT),
1190                }
1191            }
1192            InodeNamespace::Unallocated(range_id) => {
1193                self.ensure_unallocated_cache();
1194                let range_info = {
1195                    let cache = self.unallocated_cache.borrow();
1196                    cache.as_ref().and_then(|entries| {
1197                        entries.get(range_id as usize).map(|e| FsBlockRange {
1198                            start: e.start,
1199                            length: e.length,
1200                        })
1201                    })
1202                };
1203                match range_info {
1204                    Some(range) => {
1205                        let mut fs = self.fs.borrow_mut();
1206                        match fs.read_unallocated(&range) {
1207                            Ok(data) => {
1208                                let off = offset as usize;
1209                                if off >= data.len() {
1210                                    reply.data(&[]);
1211                                } else {
1212                                    let end = (off + size as usize).min(data.len());
1213                                    reply.data(&data[off..end]);
1214                                }
1215                            }
1216                            Err(_) => reply.error(libc::EIO),
1217                        }
1218                    }
1219                    None => reply.error(libc::ENOENT),
1220                }
1221            }
1222            InodeNamespace::Ro(fs_ino) => {
1223                let mut fs = self.fs.borrow_mut();
1224                match fs.read_file_range(fs_ino, offset as u64, u64::from(size)) {
1225                    Ok(data) => reply.data(&data),
1226                    Err(_) => reply.error(libc::EIO),
1227                }
1228            }
1229            InodeNamespace::Rw(rw_id) => {
1230                // Check if this is a created overlay file.
1231                if rw_id >= 9_000_000 {
1232                    let counter = rw_id - 9_000_000;
1233                    let created_id = Self::created_overlay_id(counter);
1234                    let session = self.session.borrow();
1235                    if let Some(s) = session.as_ref() {
1236                        if s.overlay.created.contains_key(&created_id)
1237                            || s.overlay.dirs.contains_key(&created_id)
1238                        {
1239                            if let Ok(data) = s.read_overlay_file(&created_id) {
1240                                let off = offset as usize;
1241                                let end = (off + size as usize).min(data.len());
1242                                if off >= data.len() {
1243                                    reply.data(&[]);
1244                                } else {
1245                                    reply.data(&data[off..end]);
1246                                }
1247                                return;
1248                            }
1249                            reply.error(libc::EIO);
1250                            return;
1251                        }
1252                    }
1253                    reply.error(libc::ENOENT);
1254                    return;
1255                }
1256
1257                // fs inode under rw/.
1258                let fs_ino = rw_id;
1259                // Check if modified in overlay.
1260                let session = self.session.borrow();
1261                if let Some(s) = session.as_ref() {
1262                    let overlay_id = Self::modified_overlay_id(fs_ino);
1263                    if s.overlay.modified.contains_key(&fs_ino) {
1264                        if let Ok(data) = s.read_overlay_file(&overlay_id) {
1265                            let off = offset as usize;
1266                            let end = (off + size as usize).min(data.len());
1267                            if off >= data.len() {
1268                                reply.data(&[]);
1269                            } else {
1270                                reply.data(&data[off..end]);
1271                            }
1272                            return;
1273                        }
1274                        reply.error(libc::EIO);
1275                        return;
1276                    }
1277                }
1278                drop(session);
1279
1280                // Fall back to underlying fs.
1281                let mut fs = self.fs.borrow_mut();
1282                match fs.read_file_range(fs_ino, offset as u64, u64::from(size)) {
1283                    Ok(data) => reply.data(&data),
1284                    Err(_) => reply.error(libc::EIO),
1285                }
1286            }
1287            _ => {
1288                reply.error(libc::ENOENT);
1289            }
1290        }
1291    }
1292
1293    fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) {
1294        let fs_ino = match decode_fuse_ino(ino) {
1295            InodeNamespace::Ro(fs_ino) => fs_ino,
1296            InodeNamespace::Rw(rw_id) if rw_id < 9_000_000 => rw_id,
1297            _ => {
1298                reply.error(libc::ENOENT);
1299                return;
1300            }
1301        };
1302
1303        let mut fs = self.fs.borrow_mut();
1304        match fs.read_link(fs_ino) {
1305            Ok(target) => reply.data(&target),
1306            Err(_) => reply.error(libc::EIO),
1307        }
1308    }
1309
1310    fn write(
1311        &mut self,
1312        _req: &Request,
1313        ino: u64,
1314        _fh: u64,
1315        offset: i64,
1316        data: &[u8],
1317        _write_flags: u32,
1318        _flags: i32,
1319        _lock_owner: Option<u64>,
1320        reply: ReplyWrite,
1321    ) {
1322        if !self.has_session() {
1323            reply.error(libc::EROFS);
1324            return;
1325        }
1326
1327        match decode_fuse_ino(ino) {
1328            InodeNamespace::Rw(rw_id) => {
1329                // Created overlay file.
1330                if rw_id >= 9_000_000 {
1331                    let counter = rw_id - 9_000_000;
1332                    let created_id = Self::created_overlay_id(counter);
1333                    let mut session = self.session.borrow_mut();
1334                    let s = session.as_mut().unwrap();
1335
1336                    let is_known = s.overlay.created.contains_key(&created_id)
1337                        || s.overlay.dirs.contains_key(&created_id);
1338                    if !is_known {
1339                        reply.error(libc::ENOENT);
1340                        return;
1341                    }
1342
1343                    let mut buf = s.read_overlay_file(&created_id).unwrap_or_default();
1344                    let off = offset as usize;
1345                    let end = off + data.len();
1346                    if end > buf.len() {
1347                        buf.resize(end, 0);
1348                    }
1349                    buf[off..end].copy_from_slice(data);
1350
1351                    if s.write_overlay_file(&created_id, &buf).is_err() {
1352                        reply.error(libc::EIO);
1353                        return;
1354                    }
1355
1356                    if let Some(entry) = s.overlay.created.get_mut(&created_id) {
1357                        entry.size = buf.len() as u64;
1358                    }
1359                    if let Some(entry) = s.overlay.dirs.get_mut(&created_id) {
1360                        entry.size = buf.len() as u64;
1361                    }
1362
1363                    if s.save().is_err() {
1364                        reply.error(libc::EIO);
1365                        return;
1366                    }
1367
1368                    reply.written(data.len() as u32);
1369                    return;
1370                }
1371
1372                // Existing fs inode under rw/ — COW on first write.
1373                let fs_ino = rw_id;
1374                let overlay_id = Self::modified_overlay_id(fs_ino);
1375
1376                let mut session = self.session.borrow_mut();
1377                let s = session.as_mut().unwrap();
1378
1379                if !s.overlay.modified.contains_key(&fs_ino) {
1380                    let mut fs = self.fs.borrow_mut();
1381                    let Ok(original) = fs.read_file(fs_ino) else {
1382                        reply.error(libc::EIO);
1383                        return;
1384                    };
1385                    if s.write_overlay_file(&overlay_id, &original).is_err() {
1386                        reply.error(libc::EIO);
1387                        return;
1388                    }
1389                    s.overlay.modified.insert(fs_ino, overlay_id.clone());
1390                }
1391
1392                let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
1393                let off = offset as usize;
1394                let end = off + data.len();
1395                if end > buf.len() {
1396                    buf.resize(end, 0);
1397                }
1398                buf[off..end].copy_from_slice(data);
1399
1400                if s.write_overlay_file(&overlay_id, &buf).is_err() {
1401                    reply.error(libc::EIO);
1402                    return;
1403                }
1404
1405                if s.save().is_err() {
1406                    reply.error(libc::EIO);
1407                    return;
1408                }
1409
1410                reply.written(data.len() as u32);
1411            }
1412            _ => reply.error(libc::EROFS),
1413        }
1414    }
1415
1416    fn create(
1417        &mut self,
1418        _req: &Request,
1419        parent: u64,
1420        name: &OsStr,
1421        _mode: u32,
1422        _umask: u32,
1423        _flags: i32,
1424        reply: ReplyCreate,
1425    ) {
1426        if !self.has_session() {
1427            reply.error(libc::EROFS);
1428            return;
1429        }
1430
1431        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
1432            reply.error(libc::EROFS);
1433            return;
1434        };
1435
1436        let Some(name_s) = name.to_str() else {
1437            reply.error(libc::EINVAL);
1438            return;
1439        };
1440        let name_str = name_s.to_string();
1441
1442        let counter = self.alloc_overlay_ino();
1443        let created_id = Self::created_overlay_id(counter);
1444        let fuse_ino = rw_ino(counter + 9_000_000);
1445
1446        let mut session = self.session.borrow_mut();
1447        let s = session.as_mut().unwrap();
1448
1449        if s.write_overlay_file(&created_id, &[]).is_err() {
1450            reply.error(libc::EIO);
1451            return;
1452        }
1453
1454        s.overlay.created.insert(
1455            created_id,
1456            crate::session::OverlayEntry {
1457                parent_ino: fs_parent,
1458                name: name_str,
1459                size: 0,
1460            },
1461        );
1462
1463        if s.save().is_err() {
1464            reply.error(libc::EIO);
1465            return;
1466        }
1467
1468        let attr = Self::overlay_created_attr(fuse_ino, 0, false);
1469        reply.created(&TTL, &attr, 0, 0, 0);
1470    }
1471
1472    fn mkdir(
1473        &mut self,
1474        _req: &Request,
1475        parent: u64,
1476        name: &OsStr,
1477        _mode: u32,
1478        _umask: u32,
1479        reply: ReplyEntry,
1480    ) {
1481        if !self.has_session() {
1482            reply.error(libc::EROFS);
1483            return;
1484        }
1485
1486        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
1487            reply.error(libc::EROFS);
1488            return;
1489        };
1490
1491        let Some(name_s) = name.to_str() else {
1492            reply.error(libc::EINVAL);
1493            return;
1494        };
1495        let name_str = name_s.to_string();
1496
1497        let counter = self.alloc_overlay_ino();
1498        let created_id = Self::created_overlay_id(counter);
1499        let fuse_ino = rw_ino(counter + 9_000_000);
1500
1501        let mut session = self.session.borrow_mut();
1502        let s = session.as_mut().unwrap();
1503
1504        s.overlay.dirs.insert(
1505            created_id,
1506            crate::session::OverlayEntry {
1507                parent_ino: fs_parent,
1508                name: name_str,
1509                size: 0,
1510            },
1511        );
1512
1513        if s.save().is_err() {
1514            reply.error(libc::EIO);
1515            return;
1516        }
1517
1518        let attr = Self::overlay_created_attr(fuse_ino, 0, true);
1519        reply.entry(&TTL, &attr, 0);
1520    }
1521
1522    fn unlink(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEmpty) {
1523        if !self.has_session() {
1524            reply.error(libc::EROFS);
1525            return;
1526        }
1527
1528        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
1529            reply.error(libc::EROFS);
1530            return;
1531        };
1532
1533        let name_bytes = name.as_encoded_bytes();
1534
1535        // Check if it's a created overlay file first.
1536        if let Some((id, _counter, _is_dir)) = self.find_created_by_name(fs_parent, name_bytes) {
1537            let mut session = self.session.borrow_mut();
1538            let s = session.as_mut().unwrap();
1539            s.overlay.created.remove(&id);
1540            s.overlay.dirs.remove(&id);
1541            let _ = std::fs::remove_file(s.overlay_file_path(&id));
1542            if s.save().is_err() {
1543                reply.error(libc::EIO);
1544                return;
1545            }
1546            reply.ok();
1547            return;
1548        }
1549
1550        // Look up the fs inode and add whiteout.
1551        let mut fs = self.fs.borrow_mut();
1552        match fs.lookup(fs_parent, name_bytes) {
1553            Ok(Some(child_ino)) => {
1554                let mut session = self.session.borrow_mut();
1555                let s = session.as_mut().unwrap();
1556                if !s.overlay.deleted.contains(&child_ino) {
1557                    s.overlay.deleted.push(child_ino);
1558                }
1559                if s.save().is_err() {
1560                    reply.error(libc::EIO);
1561                    return;
1562                }
1563                reply.ok();
1564            }
1565            Ok(None) => reply.error(libc::ENOENT),
1566            Err(_) => reply.error(libc::EIO),
1567        }
1568    }
1569
1570    fn rmdir(&mut self, req: &Request, parent: u64, name: &OsStr, reply: ReplyEmpty) {
1571        self.unlink(req, parent, name, reply);
1572    }
1573
1574    #[allow(clippy::too_many_arguments)]
1575    fn setattr(
1576        &mut self,
1577        _req: &Request,
1578        ino: u64,
1579        _mode: Option<u32>,
1580        _uid: Option<u32>,
1581        _gid: Option<u32>,
1582        size: Option<u64>,
1583        _atime: Option<TimeOrNow>,
1584        _mtime: Option<TimeOrNow>,
1585        _ctime: Option<SystemTime>,
1586        _fh: Option<u64>,
1587        _crtime: Option<SystemTime>,
1588        _chgtime: Option<SystemTime>,
1589        _bkuptime: Option<SystemTime>,
1590        _flags: Option<u32>,
1591        reply: ReplyAttr,
1592    ) {
1593        match decode_fuse_ino(ino) {
1594            InodeNamespace::Rw(rw_id) => {
1595                if !self.has_session() {
1596                    reply.error(libc::EROFS);
1597                    return;
1598                }
1599
1600                // Handle truncate (size change).
1601                if let Some(new_size) = size {
1602                    // Created overlay file.
1603                    if rw_id >= 9_000_000 {
1604                        let counter = rw_id - 9_000_000;
1605                        let created_id = Self::created_overlay_id(counter);
1606                        let mut session = self.session.borrow_mut();
1607                        let s = session.as_mut().unwrap();
1608
1609                        let mut buf = s.read_overlay_file(&created_id).unwrap_or_default();
1610                        buf.resize(new_size as usize, 0);
1611                        if s.write_overlay_file(&created_id, &buf).is_err() {
1612                            reply.error(libc::EIO);
1613                            return;
1614                        }
1615
1616                        if let Some(entry) = s.overlay.created.get_mut(&created_id) {
1617                            entry.size = new_size;
1618                        }
1619                        if let Some(entry) = s.overlay.dirs.get_mut(&created_id) {
1620                            entry.size = new_size;
1621                        }
1622                        if s.save().is_err() {
1623                            reply.error(libc::EIO);
1624                            return;
1625                        }
1626
1627                        let attr = Self::overlay_created_attr(ino, new_size, false);
1628                        reply.attr(&TTL, &attr);
1629                        return;
1630                    }
1631
1632                    // Existing fs inode — COW then truncate.
1633                    let fs_ino = rw_id;
1634                    let overlay_id = Self::modified_overlay_id(fs_ino);
1635
1636                    let mut session = self.session.borrow_mut();
1637                    let s = session.as_mut().unwrap();
1638
1639                    if !s.overlay.modified.contains_key(&fs_ino) {
1640                        let mut fs = self.fs.borrow_mut();
1641                        let Ok(original) = fs.read_file(fs_ino) else {
1642                            reply.error(libc::EIO);
1643                            return;
1644                        };
1645                        if s.write_overlay_file(&overlay_id, &original).is_err() {
1646                            reply.error(libc::EIO);
1647                            return;
1648                        }
1649                        s.overlay.modified.insert(fs_ino, overlay_id.clone());
1650                    }
1651
1652                    let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
1653                    buf.resize(new_size as usize, 0);
1654                    if s.write_overlay_file(&overlay_id, &buf).is_err() {
1655                        reply.error(libc::EIO);
1656                        return;
1657                    }
1658                    if s.save().is_err() {
1659                        reply.error(libc::EIO);
1660                        return;
1661                    }
1662
1663                    // Return updated attrs.
1664                    let mut fs = self.fs.borrow_mut();
1665                    match fs.metadata(fs_ino) {
1666                        Ok(meta) => {
1667                            let mut attr = fs_to_attr(ino, &meta);
1668                            attr.size = new_size;
1669                            attr.blocks = new_size.div_ceil(512);
1670                            reply.attr(&TTL, &attr);
1671                        }
1672                        Err(_) => reply.error(libc::EIO),
1673                    }
1674                    return;
1675                }
1676
1677                // No size change — just return current attrs.
1678                if rw_id >= 9_000_000 {
1679                    let counter = rw_id - 9_000_000;
1680                    let created_id = Self::created_overlay_id(counter);
1681                    let session = self.session.borrow();
1682                    if let Some(s) = session.as_ref() {
1683                        if let Some(entry) = s.overlay.created.get(&created_id) {
1684                            let attr = Self::overlay_created_attr(ino, entry.size, false);
1685                            reply.attr(&TTL, &attr);
1686                            return;
1687                        }
1688                        if let Some(entry) = s.overlay.dirs.get(&created_id) {
1689                            let attr = Self::overlay_created_attr(ino, entry.size, true);
1690                            reply.attr(&TTL, &attr);
1691                            return;
1692                        }
1693                    }
1694                    reply.error(libc::ENOENT);
1695                } else {
1696                    let fs_ino = rw_id;
1697                    let mut fs = self.fs.borrow_mut();
1698                    match fs.metadata(fs_ino) {
1699                        Ok(meta) => {
1700                            let mut attr = fs_to_attr(ino, &meta);
1701                            let session = self.session.borrow();
1702                            if let Some(s) = session.as_ref() {
1703                                let overlay_id = Self::modified_overlay_id(fs_ino);
1704                                if s.overlay.modified.contains_key(&fs_ino) {
1705                                    if let Ok(data) = s.read_overlay_file(&overlay_id) {
1706                                        attr.size = data.len() as u64;
1707                                        attr.blocks = attr.size.div_ceil(512);
1708                                    }
1709                                }
1710                            }
1711                            reply.attr(&TTL, &attr);
1712                        }
1713                        Err(_) => reply.error(libc::EIO),
1714                    }
1715                }
1716            }
1717            // For non-rw inodes, setattr is not supported.
1718            _ => reply.error(libc::EROFS),
1719        }
1720    }
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725    use super::*;
1726    use crate::{FsDeletedInode, FsDirEntry, FsError, FsRecoveryResult, FsResult, FsTimelineEvent};
1727    use fuser::FileType;
1728    use std::time::{Duration, UNIX_EPOCH};
1729
1730    // -----------------------------------------------------------------------
1731    // virtual_dir_attr
1732    // -----------------------------------------------------------------------
1733
1734    #[test]
1735    fn virtual_dir_attr_is_directory() {
1736        let attr = virtual_dir_attr(1);
1737        assert_eq!(attr.ino, 1);
1738        assert_eq!(attr.kind, FileType::Directory);
1739        assert_eq!(attr.perm, 0o555);
1740        assert_eq!(attr.nlink, 2);
1741        assert_eq!(attr.size, 0);
1742        assert_eq!(attr.blocks, 0);
1743        assert_eq!(attr.uid, 0);
1744        assert_eq!(attr.gid, 0);
1745        assert_eq!(attr.blksize, 4096);
1746        assert_eq!(attr.atime, UNIX_EPOCH);
1747        assert_eq!(attr.mtime, UNIX_EPOCH);
1748        assert_eq!(attr.ctime, UNIX_EPOCH);
1749        assert_eq!(attr.crtime, UNIX_EPOCH);
1750    }
1751
1752    #[test]
1753    fn virtual_dir_attr_preserves_ino() {
1754        for ino in [1, 42, FUSE_ROOT_INO, FUSE_DELETED_INO, 999_999] {
1755            assert_eq!(virtual_dir_attr(ino).ino, ino);
1756        }
1757    }
1758
1759    // -----------------------------------------------------------------------
1760    // virtual_file_attr
1761    // -----------------------------------------------------------------------
1762
1763    #[test]
1764    fn virtual_file_attr_regular() {
1765        let attr = virtual_file_attr(100, 4096);
1766        assert_eq!(attr.ino, 100);
1767        assert_eq!(attr.kind, FileType::RegularFile);
1768        assert_eq!(attr.perm, 0o444);
1769        assert_eq!(attr.nlink, 1);
1770        assert_eq!(attr.size, 4096);
1771        assert_eq!(attr.blocks, 8); // 4096 / 512
1772    }
1773
1774    #[test]
1775    fn virtual_file_attr_zero_size() {
1776        let attr = virtual_file_attr(1, 0);
1777        assert_eq!(attr.size, 0);
1778        assert_eq!(attr.blocks, 0);
1779    }
1780
1781    #[test]
1782    fn virtual_file_attr_non_512_aligned() {
1783        // 1000 bytes -> ceil(1000/512) = 2 blocks
1784        let attr = virtual_file_attr(1, 1000);
1785        assert_eq!(attr.blocks, 2);
1786    }
1787
1788    // -----------------------------------------------------------------------
1789    // ts_to_systime
1790    // -----------------------------------------------------------------------
1791
1792    #[test]
1793    fn timestamp_conversion_positive() {
1794        let ts = FsTimestamp {
1795            seconds: 1_700_000_000,
1796            nanoseconds: 500_000_000,
1797        };
1798        let st = ts_to_systime(&ts);
1799        let dur = st.duration_since(UNIX_EPOCH).unwrap();
1800        assert_eq!(dur.as_secs(), 1_700_000_000);
1801        assert_eq!(dur.subsec_nanos(), 500_000_000);
1802    }
1803
1804    #[test]
1805    fn timestamp_zero() {
1806        let ts = FsTimestamp {
1807            seconds: 0,
1808            nanoseconds: 0,
1809        };
1810        let st = ts_to_systime(&ts);
1811        assert_eq!(st, UNIX_EPOCH);
1812    }
1813
1814    #[test]
1815    fn timestamp_negative_clamps_to_epoch() {
1816        let ts = FsTimestamp {
1817            seconds: -1,
1818            nanoseconds: 0,
1819        };
1820        let st = ts_to_systime(&ts);
1821        assert_eq!(st, UNIX_EPOCH);
1822    }
1823
1824    #[test]
1825    fn timestamp_negative_large_clamps_to_epoch() {
1826        let ts = FsTimestamp {
1827            seconds: -1_000_000,
1828            nanoseconds: 999_999_999,
1829        };
1830        let st = ts_to_systime(&ts);
1831        assert_eq!(st, UNIX_EPOCH);
1832    }
1833
1834    #[test]
1835    fn timestamp_epoch_plus_one_second() {
1836        let ts = FsTimestamp {
1837            seconds: 1,
1838            nanoseconds: 0,
1839        };
1840        let st = ts_to_systime(&ts);
1841        assert_eq!(st, UNIX_EPOCH + Duration::from_secs(1));
1842    }
1843
1844    // -----------------------------------------------------------------------
1845    // fs_file_type_to_fuse
1846    // -----------------------------------------------------------------------
1847
1848    #[test]
1849    fn file_type_mapping_regular() {
1850        assert_eq!(
1851            fs_file_type_to_fuse(FsFileType::RegularFile),
1852            FileType::RegularFile
1853        );
1854    }
1855
1856    #[test]
1857    fn file_type_mapping_directory() {
1858        assert_eq!(
1859            fs_file_type_to_fuse(FsFileType::Directory),
1860            FileType::Directory
1861        );
1862    }
1863
1864    #[test]
1865    fn file_type_mapping_symlink() {
1866        assert_eq!(fs_file_type_to_fuse(FsFileType::Symlink), FileType::Symlink);
1867    }
1868
1869    #[test]
1870    fn file_type_mapping_chardev() {
1871        assert_eq!(
1872            fs_file_type_to_fuse(FsFileType::CharDevice),
1873            FileType::CharDevice
1874        );
1875    }
1876
1877    #[test]
1878    fn file_type_mapping_blockdev() {
1879        assert_eq!(
1880            fs_file_type_to_fuse(FsFileType::BlockDevice),
1881            FileType::BlockDevice
1882        );
1883    }
1884
1885    #[test]
1886    fn file_type_mapping_fifo() {
1887        assert_eq!(fs_file_type_to_fuse(FsFileType::Fifo), FileType::NamedPipe);
1888    }
1889
1890    #[test]
1891    fn file_type_mapping_socket() {
1892        assert_eq!(fs_file_type_to_fuse(FsFileType::Socket), FileType::Socket);
1893    }
1894
1895    #[test]
1896    fn file_type_mapping_unknown() {
1897        assert_eq!(
1898            fs_file_type_to_fuse(FsFileType::Unknown),
1899            FileType::RegularFile
1900        );
1901    }
1902
1903    // -----------------------------------------------------------------------
1904    // fs_to_attr
1905    // -----------------------------------------------------------------------
1906
1907    #[test]
1908    fn fs_to_attr_regular_file() {
1909        let meta = FsMetadata {
1910            ino: 42,
1911            file_type: FsFileType::RegularFile,
1912            mode: 0o100_644,
1913            uid: 1000,
1914            gid: 1000,
1915            size: 100,
1916            links_count: 1,
1917            atime: FsTimestamp {
1918                seconds: 1_700_000_000,
1919                nanoseconds: 0,
1920            },
1921            mtime: FsTimestamp {
1922                seconds: 1_700_000_000,
1923                nanoseconds: 0,
1924            },
1925            ctime: FsTimestamp {
1926                seconds: 1_700_000_000,
1927                nanoseconds: 0,
1928            },
1929            crtime: FsTimestamp {
1930                seconds: 1_700_000_000,
1931                nanoseconds: 0,
1932            },
1933            allocated: true,
1934        };
1935        let attr = fs_to_attr(1012, &meta);
1936        assert_eq!(attr.ino, 1012);
1937        assert_eq!(attr.size, 100);
1938        assert_eq!(attr.kind, FileType::RegularFile);
1939        assert_eq!(attr.nlink, 1);
1940        assert_eq!(attr.perm, 0o644);
1941        assert_eq!(attr.uid, 1000);
1942        assert_eq!(attr.gid, 1000);
1943    }
1944
1945    #[test]
1946    fn fs_to_attr_directory() {
1947        let meta = FsMetadata {
1948            ino: 2,
1949            file_type: FsFileType::Directory,
1950            mode: 0o40755,
1951            uid: 0,
1952            gid: 0,
1953            size: 4096,
1954            links_count: 3,
1955            atime: FsTimestamp::default(),
1956            mtime: FsTimestamp::default(),
1957            ctime: FsTimestamp::default(),
1958            crtime: FsTimestamp::default(),
1959            allocated: true,
1960        };
1961        let attr = fs_to_attr(2000, &meta);
1962        assert_eq!(attr.kind, FileType::Directory);
1963        assert_eq!(attr.nlink, 3);
1964        assert_eq!(attr.perm, 0o755);
1965    }
1966
1967    #[test]
1968    fn fs_to_attr_symlink() {
1969        let meta = FsMetadata {
1970            ino: 10,
1971            file_type: FsFileType::Symlink,
1972            mode: 0o120_777,
1973            uid: 0,
1974            gid: 0,
1975            size: 11,
1976            links_count: 1,
1977            atime: FsTimestamp::default(),
1978            mtime: FsTimestamp::default(),
1979            ctime: FsTimestamp::default(),
1980            crtime: FsTimestamp::default(),
1981            allocated: true,
1982        };
1983        let attr = fs_to_attr(3000, &meta);
1984        assert_eq!(attr.kind, FileType::Symlink);
1985        assert_eq!(attr.perm, 0o777);
1986    }
1987
1988    #[test]
1989    fn fs_to_attr_blocks_calculation() {
1990        let meta = FsMetadata {
1991            ino: 42,
1992            file_type: FsFileType::RegularFile,
1993            mode: 0o100_644,
1994            uid: 0,
1995            gid: 0,
1996            size: 1000,
1997            links_count: 1,
1998            atime: FsTimestamp::default(),
1999            mtime: FsTimestamp::default(),
2000            ctime: FsTimestamp::default(),
2001            crtime: FsTimestamp::default(),
2002            allocated: true,
2003        };
2004        let attr = fs_to_attr(42, &meta);
2005        assert_eq!(attr.size, 1000);
2006        assert_eq!(attr.blocks, 2);
2007    }
2008
2009    #[test]
2010    fn fs_to_attr_blksize_always_4096() {
2011        let meta = FsMetadata {
2012            ino: 1,
2013            file_type: FsFileType::RegularFile,
2014            mode: 0o100_644,
2015            uid: 0,
2016            gid: 0,
2017            size: 0,
2018            links_count: 1,
2019            atime: FsTimestamp::default(),
2020            mtime: FsTimestamp::default(),
2021            ctime: FsTimestamp::default(),
2022            crtime: FsTimestamp::default(),
2023            allocated: true,
2024        };
2025        let attr = fs_to_attr(1, &meta);
2026        assert_eq!(attr.blksize, 4096);
2027    }
2028
2029    // -----------------------------------------------------------------------
2030    // ForensicFuseFs::overlay_created_attr
2031    // -----------------------------------------------------------------------
2032
2033    #[test]
2034    fn overlay_created_attr_regular_file() {
2035        let attr = ForensicFuseFs::overlay_created_attr(999, 512, false);
2036        assert_eq!(attr.ino, 999);
2037        assert_eq!(attr.size, 512);
2038        assert_eq!(attr.kind, FileType::RegularFile);
2039        assert_eq!(attr.perm, 0o644);
2040        assert_eq!(attr.nlink, 1);
2041        assert_eq!(attr.blocks, 1);
2042    }
2043
2044    #[test]
2045    fn overlay_created_attr_directory() {
2046        let attr = ForensicFuseFs::overlay_created_attr(888, 0, true);
2047        assert_eq!(attr.kind, FileType::Directory);
2048        assert_eq!(attr.perm, 0o755);
2049    }
2050
2051    // -----------------------------------------------------------------------
2052    // ForensicFuseFs helper methods (static/associated)
2053    // -----------------------------------------------------------------------
2054
2055    #[test]
2056    fn modified_overlay_id_format() {
2057        assert_eq!(ForensicFuseFs::modified_overlay_id(42), "ino_42");
2058        assert_eq!(ForensicFuseFs::modified_overlay_id(0), "ino_0");
2059        assert_eq!(
2060            ForensicFuseFs::modified_overlay_id(9_999_999),
2061            "ino_9999999"
2062        );
2063    }
2064
2065    #[test]
2066    fn created_overlay_id_format() {
2067        assert_eq!(ForensicFuseFs::created_overlay_id(1), "new_1");
2068        assert_eq!(ForensicFuseFs::created_overlay_id(0), "new_0");
2069    }
2070
2071    // -----------------------------------------------------------------------
2072    // root_children — MountLayout decision (Humble Object)
2073    // -----------------------------------------------------------------------
2074
2075    fn root_child_names(layout: crate::MountLayout) -> Vec<String> {
2076        let mut fs = MockForensicFs;
2077        let root = fs.root_ino();
2078        root_children(layout, &mut fs, root)
2079            .unwrap()
2080            .iter()
2081            .map(|(_, n, _)| String::from_utf8_lossy(n).to_string())
2082            .collect()
2083    }
2084
2085    #[test]
2086    fn root_children_raw_lists_fs_tree_without_overlay() {
2087        let names = root_child_names(crate::MountLayout::Raw);
2088        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
2089        assert!(names.contains(&"subdir".to_string()), "got {names:?}");
2090        assert!(
2091            !names
2092                .iter()
2093                .any(|n| n == "rw" || n == "deleted" || n == "ro"),
2094            "Raw root must have no overlay dirs: {names:?}"
2095        );
2096    }
2097
2098    #[test]
2099    fn root_children_diskoverlay_lists_virtual_dirs() {
2100        let names = root_child_names(crate::MountLayout::DiskOverlay);
2101        for d in [
2102            "ro",
2103            "rw",
2104            "deleted",
2105            "journal",
2106            "metadata",
2107            "unallocated",
2108            "session",
2109        ] {
2110            assert!(names.contains(&d.to_string()), "missing {d}: {names:?}");
2111        }
2112    }
2113
2114    // -----------------------------------------------------------------------
2115    // VIRTUAL_DIRS constant
2116    // -----------------------------------------------------------------------
2117
2118    #[test]
2119    fn virtual_dirs_has_expected_entries() {
2120        assert_eq!(VIRTUAL_DIRS.len(), 7);
2121        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "ro"));
2122        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "rw"));
2123        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "deleted"));
2124        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "journal"));
2125        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "metadata"));
2126        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "unallocated"));
2127        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "session"));
2128    }
2129
2130    #[test]
2131    fn virtual_dirs_ino_matches_constants() {
2132        for &(ino, name) in VIRTUAL_DIRS {
2133            match name {
2134                "ro" => assert_eq!(ino, FUSE_RO_INO),
2135                "rw" => assert_eq!(ino, FUSE_RW_INO),
2136                "deleted" => assert_eq!(ino, FUSE_DELETED_INO),
2137                "journal" => assert_eq!(ino, FUSE_JOURNAL_INO),
2138                "metadata" => assert_eq!(ino, FUSE_METADATA_INO),
2139                "unallocated" => assert_eq!(ino, FUSE_UNALLOCATED_INO),
2140                "session" => assert_eq!(ino, FUSE_SESSION_INO),
2141                _ => panic!("unexpected virtual dir: {name}"),
2142            }
2143        }
2144    }
2145
2146    // -----------------------------------------------------------------------
2147    // MockForensicFs + FUSE dispatch tests
2148    // -----------------------------------------------------------------------
2149
2150    struct MockForensicFs;
2151
2152    impl crate::ForensicFs for MockForensicFs {
2153        fn root_ino(&self) -> u64 {
2154            2
2155        }
2156
2157        fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
2158            match ino {
2159                2 => Ok(vec![
2160                    FsDirEntry {
2161                        inode: 2,
2162                        name: b".".to_vec(),
2163                        file_type: FsFileType::Directory,
2164                    },
2165                    FsDirEntry {
2166                        inode: 2,
2167                        name: b"..".to_vec(),
2168                        file_type: FsFileType::Directory,
2169                    },
2170                    FsDirEntry {
2171                        inode: 10,
2172                        name: b"hello.txt".to_vec(),
2173                        file_type: FsFileType::RegularFile,
2174                    },
2175                    FsDirEntry {
2176                        inode: 11,
2177                        name: b"subdir".to_vec(),
2178                        file_type: FsFileType::Directory,
2179                    },
2180                ]),
2181                11 => Ok(vec![
2182                    FsDirEntry {
2183                        inode: 11,
2184                        name: b".".to_vec(),
2185                        file_type: FsFileType::Directory,
2186                    },
2187                    FsDirEntry {
2188                        inode: 2,
2189                        name: b"..".to_vec(),
2190                        file_type: FsFileType::Directory,
2191                    },
2192                    FsDirEntry {
2193                        inode: 12,
2194                        name: b"nested.txt".to_vec(),
2195                        file_type: FsFileType::RegularFile,
2196                    },
2197                ]),
2198                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2199            }
2200        }
2201
2202        fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
2203            let entries = self.read_dir(parent_ino)?;
2204            Ok(entries.iter().find(|e| e.name == name).map(|e| e.inode))
2205        }
2206
2207        fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
2208            let (file_type, size) = match ino {
2209                2 | 11 => (FsFileType::Directory, 4096),
2210                10 => (FsFileType::RegularFile, 12),
2211                12 => (FsFileType::RegularFile, 11),
2212                _ => return Err(FsError::NotFound(format!("inode {ino}"))),
2213            };
2214            Ok(FsMetadata {
2215                ino,
2216                file_type,
2217                mode: if file_type == FsFileType::Directory {
2218                    0o40755
2219                } else {
2220                    0o100_644
2221                },
2222                uid: 1000,
2223                gid: 1000,
2224                size: size as u64,
2225                links_count: if file_type == FsFileType::Directory {
2226                    2
2227                } else {
2228                    1
2229                },
2230                atime: FsTimestamp {
2231                    seconds: 1_700_000_000,
2232                    nanoseconds: 0,
2233                },
2234                mtime: FsTimestamp {
2235                    seconds: 1_700_000_000,
2236                    nanoseconds: 0,
2237                },
2238                ctime: FsTimestamp {
2239                    seconds: 1_700_000_000,
2240                    nanoseconds: 0,
2241                },
2242                crtime: FsTimestamp {
2243                    seconds: 1_699_000_000,
2244                    nanoseconds: 0,
2245                },
2246                allocated: true,
2247            })
2248        }
2249
2250        fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
2251            match ino {
2252                10 => Ok(b"Hello, mock!".to_vec()),
2253                12 => Ok(b"Nested file".to_vec()),
2254                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2255            }
2256        }
2257
2258        fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
2259            let data = self.read_file(ino)?;
2260            let start = (offset as usize).min(data.len());
2261            let end = (start + len as usize).min(data.len());
2262            Ok(data[start..end].to_vec())
2263        }
2264
2265        fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
2266            Err(FsError::NotFound("no symlinks in mock".to_string()))
2267        }
2268
2269        fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
2270            Ok(vec![FsDeletedInode {
2271                ino: 99,
2272                file_type: FsFileType::RegularFile,
2273                size: 100,
2274                dtime: 1_700_001_000,
2275                recoverability: 0.75,
2276            }])
2277        }
2278
2279        fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
2280            match ino {
2281                99 => Ok(FsRecoveryResult {
2282                    ino: 99,
2283                    data: vec![0xDE; 100],
2284                    expected_size: 100,
2285                    recovered_bytes: 100,
2286                    recovery_percentage: 1.0,
2287                }),
2288                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2289            }
2290        }
2291
2292        fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
2293            Ok(vec![FsTimelineEvent {
2294                timestamp: FsTimestamp {
2295                    seconds: 1_700_000_000,
2296                    nanoseconds: 0,
2297                },
2298                event_type: FsEventType::Created,
2299                inode: 10,
2300                size: 12,
2301                uid: 1000,
2302                gid: 1000,
2303            }])
2304        }
2305
2306        fn fs_info(&self) -> FsResult<serde_json::Value> {
2307            Ok(serde_json::json!({ "filesystem": "mock", "block_size": 4096 }))
2308        }
2309
2310        fn block_size(&self) -> u64 {
2311            4096
2312        }
2313    }
2314
2315    fn make_mock_fuse() -> ForensicFuseFs {
2316        ForensicFuseFs::new(
2317            Box::new(MockForensicFs),
2318            None,
2319            crate::MountLayout::DiskOverlay,
2320        )
2321    }
2322
2323    #[test]
2324    fn mock_ensure_deleted_cache() {
2325        let fuse = make_mock_fuse();
2326        assert!(fuse.deleted_cache.borrow().is_none());
2327        fuse.ensure_deleted_cache();
2328        let cache = fuse.deleted_cache.borrow();
2329        let entries = cache.as_ref().expect("cache should be populated");
2330        assert_eq!(entries.len(), 1);
2331        assert_eq!(entries[0].fs_ino, 99);
2332        assert_eq!(entries[0].name, "99_unknown");
2333        assert_eq!(entries[0].data.len(), 100);
2334    }
2335
2336    #[test]
2337    fn mock_ensure_metadata_cache() {
2338        let fuse = make_mock_fuse();
2339        assert!(fuse.metadata_cache.borrow().is_none());
2340        fuse.ensure_metadata_cache();
2341        let cache = fuse.metadata_cache.borrow();
2342        let mc = cache.as_ref().expect("cache should be populated");
2343        let sb_str = String::from_utf8_lossy(&mc.superblock_json);
2344        assert!(
2345            sb_str.contains("mock"),
2346            "superblock_json should contain 'mock': {sb_str}"
2347        );
2348        assert!(
2349            !mc.timeline_jsonl.is_empty(),
2350            "timeline_jsonl should not be empty"
2351        );
2352    }
2353
2354    #[test]
2355    fn mock_root_ino_stored() {
2356        let fuse = make_mock_fuse();
2357        assert_eq!(fuse.root_ino, 2);
2358    }
2359
2360    #[test]
2361    fn mock_has_session_false() {
2362        let fuse = make_mock_fuse();
2363        assert!(!fuse.has_session());
2364    }
2365
2366    #[test]
2367    fn mock_read_file_through_fs() {
2368        let fuse = make_mock_fuse();
2369        let mut fs = fuse.fs.borrow_mut();
2370        let data = fs.read_file(10).expect("read_file(10) should succeed");
2371        assert_eq!(data, b"Hello, mock!");
2372    }
2373
2374    #[test]
2375    fn mock_read_file_range_through_fs() {
2376        let fuse = make_mock_fuse();
2377        let mut fs = fuse.fs.borrow_mut();
2378        let data = fs
2379            .read_file_range(10, 0, 5)
2380            .expect("read_file_range should succeed");
2381        assert_eq!(data, b"Hello");
2382    }
2383
2384    #[test]
2385    fn mock_lookup_through_fs() {
2386        let fuse = make_mock_fuse();
2387        let mut fs = fuse.fs.borrow_mut();
2388        let result = fs.lookup(2, b"hello.txt").expect("lookup should succeed");
2389        assert_eq!(result, Some(10));
2390    }
2391
2392    #[test]
2393    fn mock_metadata_through_fs() {
2394        let fuse = make_mock_fuse();
2395        let mut fs = fuse.fs.borrow_mut();
2396        let meta = fs.metadata(10).expect("metadata(10) should succeed");
2397        assert_eq!(meta.file_type, FsFileType::RegularFile);
2398        assert_eq!(meta.size, 12);
2399        assert_eq!(meta.ino, 10);
2400    }
2401
2402    #[test]
2403    fn mock_fs_to_attr() {
2404        let fuse = make_mock_fuse();
2405        let meta = {
2406            let mut fs = fuse.fs.borrow_mut();
2407            fs.metadata(10).expect("metadata(10) should succeed")
2408        };
2409        let attr = fs_to_attr(ro_ino(10), &meta);
2410        assert_eq!(attr.ino, ro_ino(10));
2411        assert_eq!(attr.kind, FileType::RegularFile);
2412        assert_eq!(attr.size, 12);
2413        assert_eq!(attr.perm, 0o644);
2414        assert_eq!(attr.uid, 1000);
2415        assert_eq!(attr.gid, 1000);
2416        assert_eq!(attr.nlink, 1);
2417        assert_eq!(attr.blksize, 4096);
2418        let expected_atime = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
2419        assert_eq!(attr.atime, expected_atime);
2420        let expected_crtime = UNIX_EPOCH + Duration::from_secs(1_699_000_000);
2421        assert_eq!(attr.crtime, expected_crtime);
2422    }
2423
2424    #[test]
2425    fn mock_timeline_through_fs() {
2426        let fuse = make_mock_fuse();
2427        let mut fs = fuse.fs.borrow_mut();
2428        let events = fs.timeline().expect("timeline should succeed");
2429        assert_eq!(events.len(), 1);
2430        assert_eq!(events[0].event_type, FsEventType::Created);
2431        assert_eq!(events[0].inode, 10);
2432        assert_eq!(events[0].size, 12);
2433    }
2434
2435    #[test]
2436    fn mock_ensure_journal_cache_empty() {
2437        let fuse = make_mock_fuse();
2438        assert!(fuse.journal_cache.borrow().is_none());
2439        fuse.ensure_journal_cache();
2440        let cache = fuse.journal_cache.borrow();
2441        let entries = cache.as_ref().expect("cache should be populated");
2442        assert!(
2443            entries.is_empty(),
2444            "mock has no journal_transactions override, should be empty"
2445        );
2446    }
2447}