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