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_JOURNAL_INO, FUSE_METADATA_INO, FUSE_ORPHANS_INO, FUSE_ROOT_INO,
6    FUSE_RO_INO, FUSE_RW_INO, FUSE_SESSION_INO, FUSE_UNALLOCATED_INO,
7};
8use crate::session::Session;
9use crate::ForensicFs;
10use crate::{
11    DeletedMode, FsAllocation, FsBlockRange, FsEventType, FsFileType, FsMetadata, FsTimestamp,
12};
13use fuser::{
14    FileAttr, FileType, Filesystem, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty,
15    ReplyEntry, ReplyWrite, ReplyXattr, Request, TimeOrNow,
16};
17use std::cell::RefCell;
18use std::ffi::OsStr;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21const TTL: Duration = Duration::from_secs(1);
22
23/// Fixed virtual directory names at the FUSE root. `$Orphans/` is appended
24/// dynamically by [`root_dir_listing`] only when recovered orphan entries
25/// exist; the old flat `deleted/` directory is gone (recovered deletes now
26/// render in-place — ADR 0008 v2).
27const VIRTUAL_DIRS: &[(u64, &str)] = &[
28    (FUSE_RO_INO, "ro"),
29    (FUSE_RW_INO, "rw"),
30    (FUSE_JOURNAL_INO, "journal"),
31    (FUSE_METADATA_INO, "metadata"),
32    (FUSE_UNALLOCATED_INO, "unallocated"),
33    (FUSE_SESSION_INO, "session"),
34];
35
36/// Root-level directory listing: the fixed [`VIRTUAL_DIRS`] plus the top-level
37/// synthetic `$Orphans/` when unplaceable recovered entries exist (ADR 0008
38/// v2). Pure, so the root readdir/lookup shells share one decision.
39fn root_dir_listing(has_orphans: bool) -> Vec<(u64, &'static str)> {
40    let mut v: Vec<(u64, &'static str)> = VIRTUAL_DIRS.to_vec();
41    if has_orphans {
42        v.push((FUSE_ORPHANS_INO, "$Orphans"));
43    }
44    v
45}
46
47/// Whether any recovered entry is routed to `$Orphans` (true orphan, live-name
48/// collision, or an older same-name delete).
49fn cache_has_orphans(entries: &[DeletedEntry]) -> bool {
50    entries.iter().any(|e| e.orphan)
51}
52
53/// Recovered MACB times carried with a deleted entry (timeline + export).
54#[derive(Default, Clone, Copy)]
55struct DeletedMacb {
56    modified: FsTimestamp,
57    accessed: FsTimestamp,
58    changed: FsTimestamp,
59    born: FsTimestamp,
60}
61
62/// Cached entry for a recovered deleted file visible under `deleted/`.
63///
64/// The name is the **real** recovered name when placed in-place, or the
65/// disambiguated `<name>@<ts>Z~<id>` form under `$Orphans` — never the old
66/// fabricated `<ino>_unknown`. `readable == false` marks content the backend
67/// could not recover, surfaced as an explicit read error rather than a
68/// fabricated 0-byte success.
69struct DeletedEntry {
70    /// Readable backend inode (reads the recovered bytes).
71    fs_ino: u64,
72    /// Display name (real, or disambiguated under `$Orphans`).
73    name: String,
74    /// The recovered real name (empty when the FS destroyed it on delete).
75    real_name: String,
76    size: u64,
77    data: Vec<u8>,
78    readable: bool,
79    /// True when routed to `$Orphans`; false when placed in-place.
80    orphan: bool,
81    /// Recovered parent inode (None for a true orphan).
82    parent_ino: Option<u64>,
83    /// Metadata record id (MFT entry / inode) — the stable disambiguator.
84    record_id: u64,
85    allocation: FsAllocation,
86    macb: DeletedMacb,
87}
88
89/// Cached entry for a journal transaction visible in the `journal/` virtual directory.
90struct JournalTxnEntry {
91    sequence: u64,
92    name: String,
93}
94
95/// Cached metadata files for the `metadata/` virtual directory.
96struct MetadataCache {
97    superblock_json: Vec<u8>,
98    timeline_jsonl: Vec<u8>,
99}
100
101/// Cached entry for an unallocated block range visible in the `unallocated/` virtual directory.
102struct UnallocatedEntry {
103    #[allow(dead_code)]
104    range_id: u64,
105    name: String,
106    start: u64,
107    length: u64,
108}
109
110pub struct ForensicFuseFs {
111    fs: RefCell<Box<dyn ForensicFs + Send>>,
112    session: RefCell<Option<Session>>,
113    /// Counter for allocating new overlay inode numbers (for created files).
114    overlay_ino_counter: RefCell<u64>,
115    /// The root inode number reported by the underlying filesystem.
116    root_ino: u64,
117    /// Lazy-loaded cache for the deleted/ virtual directory.
118    deleted_cache: RefCell<Option<Vec<DeletedEntry>>>,
119    /// Lazy-loaded cache for the journal/ virtual directory.
120    journal_cache: RefCell<Option<Vec<JournalTxnEntry>>>,
121    /// Lazy-loaded cache for the metadata/ virtual directory.
122    metadata_cache: RefCell<Option<MetadataCache>>,
123    /// Lazy-loaded cache for the unallocated/ virtual directory.
124    unallocated_cache: RefCell<Option<Vec<UnallocatedEntry>>>,
125    /// How the root is rendered: disk overlay (`ro/ rw/ …`) or raw tree.
126    layout: crate::MountLayout,
127    /// How the `deleted/` view is populated (latest / all / off).
128    deleted_mode: DeletedMode,
129}
130
131impl ForensicFuseFs {
132    pub fn new(
133        fs: Box<dyn ForensicFs + Send>,
134        session: Option<Session>,
135        layout: crate::MountLayout,
136        deleted_mode: DeletedMode,
137    ) -> Self {
138        let root_ino = fs.root_ino();
139        Self {
140            fs: RefCell::new(fs),
141            session: RefCell::new(session),
142            overlay_ino_counter: RefCell::new(1),
143            root_ino,
144            deleted_cache: RefCell::new(None),
145            journal_cache: RefCell::new(None),
146            metadata_cache: RefCell::new(None),
147            unallocated_cache: RefCell::new(None),
148            layout,
149            deleted_mode,
150        }
151    }
152
153    /// Check if a session is available (rw/ operations require one).
154    fn has_session(&self) -> bool {
155        self.session.borrow().is_some()
156    }
157
158    /// Get the overlay file ID for a modified inode.
159    fn modified_overlay_id(fs_ino: u64) -> String {
160        format!("ino_{fs_ino}")
161    }
162
163    /// Allocate a new overlay inode number for created files.
164    fn alloc_overlay_ino(&self) -> u64 {
165        let mut counter = self.overlay_ino_counter.borrow_mut();
166        let ino = *counter;
167        *counter += 1;
168        ino
169    }
170
171    /// Get the overlay file ID for a newly created file.
172    fn created_overlay_id(counter: u64) -> String {
173        format!("new_{counter}")
174    }
175
176    /// Build a `FileAttr` for an overlay-created file.
177    fn overlay_created_attr(fuse_ino: u64, size: u64, is_dir: bool) -> FileAttr {
178        let kind = if is_dir {
179            FileType::Directory
180        } else {
181            FileType::RegularFile
182        };
183        FileAttr {
184            ino: fuse_ino,
185            size,
186            blocks: size.div_ceil(512),
187            atime: SystemTime::now(),
188            mtime: SystemTime::now(),
189            ctime: SystemTime::now(),
190            crtime: SystemTime::now(),
191            kind,
192            perm: if is_dir { 0o755 } else { 0o644 },
193            nlink: 1,
194            uid: 0,
195            gid: 0,
196            rdev: 0,
197            blksize: 4096,
198            flags: 0,
199        }
200    }
201
202    /// Resolve rw/ parent inode to underlying fs parent inode.
203    fn rw_parent_to_fs(&self, parent: u64) -> Option<u64> {
204        match parent {
205            FUSE_RW_INO => Some(self.root_ino),
206            _ => match decode_fuse_ino(parent) {
207                InodeNamespace::Rw(ino) => Some(ino),
208                _ => None,
209            },
210        }
211    }
212
213    /// Check if an inode is in the whiteout (deleted) list.
214    fn is_whiteout(&self, fs_ino: u64) -> bool {
215        let session = self.session.borrow();
216        match session.as_ref() {
217            Some(s) => s.overlay.deleted.contains(&fs_ino),
218            None => false,
219        }
220    }
221
222    /// Ensure the deleted/ cache is populated from the backend's rich
223    /// `deleted_nodes()` — real names, in-place vs `$Orphans` placement, and
224    /// `--deleted` gating. No fabrication: an unreadable node is marked, never
225    /// rendered as a 0-byte success, and names are never `<ino>_unknown`.
226    fn ensure_deleted_cache(&self) {
227        if self.deleted_cache.borrow().is_some() {
228            return;
229        }
230        // `off`: a successful, empty enumeration (honest "not requested"), not a
231        // populated cache.
232        if self.deleted_mode == DeletedMode::Off {
233            *self.deleted_cache.borrow_mut() = Some(Vec::new());
234            return;
235        }
236
237        let mut fs = self.fs.borrow_mut();
238        let nodes = fs.deleted_nodes().unwrap_or_default();
239
240        // Per node: does a *live* sibling already hold this name under the
241        // recovered parent? A collision forces the entry to `$Orphans`.
242        let plans: Vec<DeletedPlan> = nodes
243            .iter()
244            .map(|n| {
245                let has_live_collision = match n.parent_ino {
246                    Some(p) if !n.name.is_empty() => fs.lookup(p, &n.name).ok().flatten().is_some(),
247                    _ => false,
248                };
249                DeletedPlan {
250                    ino: n.ino,
251                    real_name: String::from_utf8_lossy(&n.name).into_owned(),
252                    parent_ino: n.parent_ino,
253                    mtime_secs: n.mtime.seconds,
254                    record_id: n.record_id,
255                    has_live_collision,
256                    allocation: n.allocation,
257                }
258            })
259            .collect();
260
261        let placed = plan_deleted(&plans, self.deleted_mode);
262
263        let mut entries = Vec::with_capacity(placed.len());
264        for p in placed {
265            let node = nodes.iter().find(|n| n.ino == p.ino);
266            let (meta_size, macb) = node.map_or((0, DeletedMacb::default()), |n| {
267                (
268                    n.size,
269                    DeletedMacb {
270                        modified: n.mtime,
271                        accessed: n.atime,
272                        changed: n.ctime,
273                        born: n.crtime,
274                    },
275                )
276            });
277            // Content: recover the bytes; a failure is MARKED (readable=false),
278            // never fabricated into a 0-byte success. Size falls back to the
279            // recovered metadata size so an unreadable entry still shows a size.
280            let (data, readable) = match fs.read_file(p.ino) {
281                Ok(d) => (d, true),
282                Err(_) => (Vec::new(), false),
283            };
284            let size = if readable {
285                data.len() as u64
286            } else {
287                meta_size
288            };
289            entries.push(DeletedEntry {
290                fs_ino: p.ino,
291                name: p.display_name,
292                real_name: p.real_name,
293                size,
294                data,
295                readable,
296                orphan: p.orphan,
297                parent_ino: p.parent_ino,
298                record_id: p.record_id,
299                allocation: p.allocation,
300                macb,
301            });
302        }
303        *self.deleted_cache.borrow_mut() = Some(entries);
304    }
305
306    /// Ensure the journal/ cache is populated.
307    fn ensure_journal_cache(&self) {
308        if self.journal_cache.borrow().is_some() {
309            return;
310        }
311        let mut fs = self.fs.borrow_mut();
312        let entries = match fs.journal_transactions() {
313            Ok(txns) => txns
314                .iter()
315                .map(|txn| JournalTxnEntry {
316                    sequence: txn.sequence,
317                    name: format!("txn_{}", txn.sequence),
318                })
319                .collect(),
320            Err(_) => Vec::new(),
321        };
322        *self.journal_cache.borrow_mut() = Some(entries);
323    }
324
325    /// Ensure the metadata/ cache is populated.
326    fn ensure_metadata_cache(&self) {
327        if self.metadata_cache.borrow().is_some() {
328            return;
329        }
330        let fs = self.fs.borrow();
331
332        // Build superblock.json from fs_info()
333        let superblock_json = match fs.fs_info() {
334            Ok(info) => serde_json::to_string_pretty(&info)
335                .unwrap_or_default()
336                .into_bytes(),
337            Err(_) => b"{}".to_vec(),
338        };
339        drop(fs);
340
341        // Build timeline.jsonl: filesystem events first, then one row per
342        // recovered deleted instance (grep a path/name = every version of it).
343        let mut timeline_jsonl = {
344            let mut fs = self.fs.borrow_mut();
345            match fs.timeline() {
346                Ok(events) => {
347                    let mut buf = Vec::new();
348                    for event in &events {
349                        let event_type = match event.event_type {
350                            FsEventType::Created => "Created",
351                            FsEventType::Modified => "Modified",
352                            FsEventType::Accessed => "Accessed",
353                            FsEventType::Changed => "Changed",
354                            FsEventType::Deleted => "Deleted",
355                            FsEventType::Mounted => "Mounted",
356                        };
357                        let line = serde_json::json!({
358                            "timestamp_secs": event.timestamp.seconds,
359                            "timestamp_nsecs": event.timestamp.nanoseconds,
360                            "event_type": event_type,
361                            "inode": event.inode,
362                            "size": event.size,
363                            "uid": event.uid,
364                            "gid": event.gid,
365                        });
366                        let line_str = serde_json::to_string(&line).unwrap_or_default();
367                        buf.extend_from_slice(line_str.as_bytes());
368                        buf.push(b'\n');
369                    }
370                    buf
371                }
372                Err(_) => Vec::new(),
373            }
374        };
375
376        // Deleted-instance rows derived from the same deleted_nodes() data — one
377        // per instance, so every version of a same-named deleted file appears.
378        self.ensure_deleted_cache();
379        if let Some(entries) = self.deleted_cache.borrow().as_ref() {
380            for e in entries {
381                timeline_jsonl.extend_from_slice(deleted_timeline_row(e).as_bytes());
382                timeline_jsonl.push(b'\n');
383            }
384        }
385
386        *self.metadata_cache.borrow_mut() = Some(MetadataCache {
387            superblock_json,
388            timeline_jsonl,
389        });
390    }
391
392    /// Ensure the unallocated/ cache is populated.
393    fn ensure_unallocated_cache(&self) {
394        if self.unallocated_cache.borrow().is_some() {
395            return;
396        }
397        let mut fs = self.fs.borrow_mut();
398        let entries = match fs.unallocated_blocks() {
399            Ok(ranges) => ranges
400                .iter()
401                .enumerate()
402                .map(|(i, r)| UnallocatedEntry {
403                    range_id: i as u64,
404                    name: format!("blocks_{}-{}.raw", r.start, r.start + r.length),
405                    start: r.start,
406                    length: r.length,
407                })
408                .collect(),
409            Err(_) => Vec::new(),
410        };
411        *self.unallocated_cache.borrow_mut() = Some(entries);
412    }
413
414    /// Find a created overlay entry by `parent_ino` and name.
415    fn find_created_by_name(&self, parent_ino: u64, name: &[u8]) -> Option<(String, u64, bool)> {
416        let session = self.session.borrow();
417        let session = session.as_ref()?;
418        let name_str = std::str::from_utf8(name).ok()?;
419        for (id, entry) in &session.overlay.created {
420            if entry.parent_ino == parent_ino && entry.name == name_str {
421                let counter: u64 = id.strip_prefix("new_").and_then(|s| s.parse().ok())?;
422                return Some((id.clone(), counter, false));
423            }
424        }
425        for (id, entry) in &session.overlay.dirs {
426            if entry.parent_ino == parent_ino && entry.name == name_str {
427                let counter: u64 = id.strip_prefix("new_").and_then(|s| s.parse().ok())?;
428                return Some((id.clone(), counter, true));
429            }
430        }
431        None
432    }
433
434    /// Resolve a name to an in-place recovered-deleted child of `parent_fs_ino`,
435    /// returning `(deleted-namespace fuse inode, size)` when a non-orphan entry
436    /// under that parent carries exactly that real name (ADR 0008 v2). Ensures
437    /// the deleted cache first, so callers must not hold a borrow of `self.fs`.
438    fn lookup_deleted_in_place(&self, parent_fs_ino: u64, name: &[u8]) -> Option<(u64, u64)> {
439        self.ensure_deleted_cache();
440        let cache = self.deleted_cache.borrow();
441        cache.as_ref()?.iter().find_map(|e| {
442            (!e.orphan && e.parent_ino == Some(parent_fs_ino) && e.name.as_bytes() == name)
443                .then_some((deleted_ino(e.fs_ino), e.size))
444        })
445    }
446
447    /// Whether the recovered-deleted cache holds any `$Orphans` entry, so the
448    /// root shells know to surface the top-level `$Orphans/` directory.
449    fn cache_orphans_present(&self) -> bool {
450        self.ensure_deleted_cache();
451        self.deleted_cache
452            .borrow()
453            .as_ref()
454            .is_some_and(|e| cache_has_orphans(e))
455    }
456}
457
458/// Convert a `FsTimestamp` to `SystemTime`.
459fn ts_to_systime(t: &FsTimestamp) -> SystemTime {
460    if t.seconds >= 0 {
461        UNIX_EPOCH + Duration::new(t.seconds as u64, t.nanoseconds)
462    } else {
463        UNIX_EPOCH
464    }
465}
466
467/// Build a `FileAttr` from an `FsMetadata`.
468fn fs_to_attr(fuse_ino: u64, meta: &FsMetadata) -> FileAttr {
469    let kind = match meta.file_type {
470        FsFileType::RegularFile | FsFileType::Unknown => FileType::RegularFile,
471        FsFileType::Directory => FileType::Directory,
472        FsFileType::Symlink => FileType::Symlink,
473        FsFileType::CharDevice => FileType::CharDevice,
474        FsFileType::BlockDevice => FileType::BlockDevice,
475        FsFileType::Fifo => FileType::NamedPipe,
476        FsFileType::Socket => FileType::Socket,
477    };
478
479    FileAttr {
480        ino: fuse_ino,
481        size: meta.size,
482        blocks: meta.size.div_ceil(512),
483        atime: ts_to_systime(&meta.atime),
484        mtime: ts_to_systime(&meta.mtime),
485        ctime: ts_to_systime(&meta.ctime),
486        crtime: ts_to_systime(&meta.crtime),
487        kind,
488        perm: meta.mode & 0o7777,
489        nlink: u32::from(meta.links_count),
490        uid: meta.uid,
491        gid: meta.gid,
492        rdev: 0,
493        blksize: 4096,
494        flags: 0,
495    }
496}
497
498/// Build a synthetic `FileAttr` for a virtual directory.
499fn virtual_dir_attr(ino: u64) -> FileAttr {
500    FileAttr {
501        ino,
502        size: 0,
503        blocks: 0,
504        atime: UNIX_EPOCH,
505        mtime: UNIX_EPOCH,
506        ctime: UNIX_EPOCH,
507        crtime: UNIX_EPOCH,
508        kind: FileType::Directory,
509        perm: 0o555,
510        nlink: 2,
511        uid: 0,
512        gid: 0,
513        rdev: 0,
514        blksize: 4096,
515        flags: 0,
516    }
517}
518
519/// Build a synthetic `FileAttr` for a virtual read-only regular file.
520fn virtual_file_attr(ino: u64, size: u64) -> FileAttr {
521    FileAttr {
522        ino,
523        size,
524        blocks: size.div_ceil(512),
525        atime: UNIX_EPOCH,
526        mtime: UNIX_EPOCH,
527        ctime: UNIX_EPOCH,
528        crtime: UNIX_EPOCH,
529        kind: FileType::RegularFile,
530        perm: 0o444,
531        nlink: 1,
532        uid: 0,
533        gid: 0,
534        rdev: 0,
535        blksize: 4096,
536        flags: 0,
537    }
538}
539
540/// Convert an `FsFileType` to a fuser `FileType`.
541fn fs_file_type_to_fuse(t: FsFileType) -> FileType {
542    match t {
543        FsFileType::RegularFile | FsFileType::Unknown => FileType::RegularFile,
544        FsFileType::Directory => FileType::Directory,
545        FsFileType::Symlink => FileType::Symlink,
546        FsFileType::CharDevice => FileType::CharDevice,
547        FsFileType::BlockDevice => FileType::BlockDevice,
548        FsFileType::Fifo => FileType::NamedPipe,
549        FsFileType::Socket => FileType::Socket,
550    }
551}
552
553/// The entries shown at the FUSE mount root, per [`MountLayout`].
554///
555/// `DiskOverlay` lists the virtual directories (`ro/`, `rw/`, `deleted/`, …);
556/// `Raw` lists the underlying [`ForensicFs`] root's children directly (encoded
557/// into the `ro/` inode namespace so the existing sub-tree callbacks serve
558/// them), with no overlay directories. Returns `(fuse_ino, name, file_type)`;
559/// `.`/`..` are added by the caller.
560fn root_children(
561    layout: crate::MountLayout,
562    fs: &mut dyn ForensicFs,
563    root_ino: u64,
564    has_orphans: bool,
565) -> crate::FsResult<Vec<(u64, Vec<u8>, FileType)>> {
566    match layout {
567        crate::MountLayout::DiskOverlay => Ok(root_dir_listing(has_orphans)
568            .into_iter()
569            .map(|(ino, name)| (ino, name.as_bytes().to_vec(), FileType::Directory))
570            .collect()),
571        crate::MountLayout::Raw => {
572            let mut out = Vec::new();
573            for e in fs.read_dir(root_ino)? {
574                if e.name == b"." || e.name == b".." {
575                    continue;
576                }
577                // Encode into the ro/ namespace so the existing sub-tree
578                // callbacks (Ro decode) serve everything below the root.
579                out.push((ro_ino(e.inode), e.name, fs_file_type_to_fuse(e.file_type)));
580            }
581            Ok(out)
582        }
583    }
584}
585
586/// One recovered deleted node as input to the placement planner.
587struct DeletedPlan {
588    ino: u64,
589    real_name: String,
590    parent_ino: Option<u64>,
591    mtime_secs: i64,
592    record_id: u64,
593    has_live_collision: bool,
594    allocation: FsAllocation,
595}
596
597/// Planner output: where a recovered node renders and under what name.
598struct PlacedDeleted {
599    ino: u64,
600    display_name: String,
601    real_name: String,
602    orphan: bool,
603    parent_ino: Option<u64>,
604    record_id: u64,
605    allocation: FsAllocation,
606}
607
608/// Decide in-place vs `$Orphans` placement and the display name for each
609/// recovered deleted node, per ADR 0008 and the `--deleted` mode. Pure and
610/// deterministic (no filesystem access), so it is unit-tested directly:
611///
612/// - `Off`   → nothing.
613/// - `All`   → every instance under `$Orphans`, disambiguated.
614/// - `Latest`→ the newest instance of each (parent, name) group renders
615///   in-place (real name) when its parent is known, its name survived, its
616///   allocation is `Deleted`, and no live sibling holds the name; every other
617///   instance (older duplicates, collisions, true orphans) goes to `$Orphans`.
618fn plan_deleted(plans: &[DeletedPlan], mode: DeletedMode) -> Vec<PlacedDeleted> {
619    match mode {
620        DeletedMode::Off => Vec::new(),
621        DeletedMode::All => plans.iter().map(orphan_placed).collect(),
622        DeletedMode::Latest => {
623            use std::collections::{HashMap, HashSet};
624            // Winner of each (parent, name) group = the in-place candidate.
625            let mut winner: HashMap<(u64, &str), usize> = HashMap::new();
626            for (i, p) in plans.iter().enumerate() {
627                if !eligible_in_place(p) {
628                    continue;
629                }
630                let key = (p.parent_ino.unwrap_or_default(), p.real_name.as_str());
631                match winner.get(&key) {
632                    Some(&j) if !outranks(p, &plans[j]) => {}
633                    _ => {
634                        winner.insert(key, i);
635                    }
636                }
637            }
638            let winners: HashSet<usize> = winner.into_values().collect();
639            plans
640                .iter()
641                .enumerate()
642                .map(|(i, p)| {
643                    if winners.contains(&i) {
644                        PlacedDeleted {
645                            ino: p.ino,
646                            display_name: p.real_name.clone(),
647                            real_name: p.real_name.clone(),
648                            orphan: false,
649                            parent_ino: p.parent_ino,
650                            record_id: p.record_id,
651                            allocation: p.allocation,
652                        }
653                    } else {
654                        orphan_placed(p)
655                    }
656                })
657                .collect()
658        }
659    }
660}
661
662/// A node may render in place only if it is a named, parented, `Deleted`
663/// record whose name is free in the live directory.
664fn eligible_in_place(p: &DeletedPlan) -> bool {
665    p.allocation == FsAllocation::Deleted
666        && p.parent_ino.is_some()
667        && !p.real_name.is_empty()
668        && !p.has_live_collision
669}
670
671/// `a` outranks `b` for the in-place slot: newest mtime, ties to highest id.
672fn outranks(a: &DeletedPlan, b: &DeletedPlan) -> bool {
673    (a.mtime_secs, a.record_id) > (b.mtime_secs, b.record_id)
674}
675
676fn orphan_placed(p: &DeletedPlan) -> PlacedDeleted {
677    PlacedDeleted {
678        ino: p.ino,
679        display_name: orphan_name(&p.real_name, p.mtime_secs, p.record_id),
680        real_name: p.real_name.clone(),
681        orphan: true,
682        parent_ino: p.parent_ino,
683        record_id: p.record_id,
684        allocation: p.allocation,
685    }
686}
687
688/// `$Orphans` disambiguated name `<name>@<ts>Z~<id>` (the `Z` is appended by
689/// [`filename_safe_utc`]), degrading to `record-<id>[@<ts>Z]` when no name
690/// survived. The id is always present — it is the uniqueness guarantee.
691fn orphan_name(real_name: &str, mtime_secs: i64, record_id: u64) -> String {
692    let ts = (mtime_secs > 0).then(|| filename_safe_utc(mtime_secs));
693    match (real_name.is_empty(), ts) {
694        (false, Some(ts)) => format!("{real_name}@{ts}~{record_id}"),
695        (false, None) => format!("{real_name}~{record_id}"),
696        (true, Some(ts)) => format!("record-{record_id}@{ts}"),
697        (true, None) => format!("record-{record_id}"),
698    }
699}
700
701/// Format Unix seconds as a filename-safe UTC string `YYYY-MM-DDTHH-MM-SSZ`.
702/// Colons become hyphens because `:` is illegal in Windows filenames, so this
703/// timestamp can appear in any path the mount emits (ADR 0008).
704#[allow(clippy::many_single_char_names)] // conventional date-field names (y/m/d/h/s)
705fn filename_safe_utc(secs: i64) -> String {
706    let days = secs.div_euclid(86_400);
707    let tod = secs.rem_euclid(86_400);
708    let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
709    let (y, mon, d) = crate::marking::civil_from_days(days);
710    format!("{y:04}-{mon:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
711}
712
713/// One `timeline.jsonl` row for a recovered deleted instance. Emitting one row
714/// per instance makes the timeline an all-versions event list: grep a name or
715/// path and every deleted version of it appears. `placement` marks in-place vs
716/// `$Orphans`; `status` says whether the content was recoverable.
717fn deleted_timeline_row(e: &DeletedEntry) -> String {
718    // v2 rendering: orphans live under the top-level `$Orphans/`; in-place
719    // entries render at their real name in the main tree (full parent path is
720    // not reconstructed here — the record id + parent_ino carry identity).
721    let path = if e.orphan {
722        format!("$Orphans/{}", e.name)
723    } else {
724        e.real_name.clone()
725    };
726    let allocation = crate::marking::status_str(e.allocation);
727    let row = serde_json::json!({
728        "path": path,
729        "name": e.real_name,
730        "parent_ino": e.parent_ino,
731        "macb": {
732            "modified": e.macb.modified.seconds,
733            "accessed": e.macb.accessed.seconds,
734            "changed": e.macb.changed.seconds,
735            "born": e.macb.born.seconds,
736        },
737        "record_id": e.record_id,
738        "allocation": allocation,
739        "status": if e.readable { "recovered" } else { "unreadable" },
740        "placement": if e.orphan { "orphan" } else { "in-place" },
741    });
742    serde_json::to_string(&row).unwrap_or_default()
743}
744
745/// In-place recovered-deleted children of a live directory: the non-orphan
746/// entries whose recovered parent is `parent_fs_ino`, returned as
747/// `(deleted-namespace fuse inode, real name)`. The main-tree readdir/lookup
748/// inject these beside the live siblings so a recovered deleted file appears at
749/// its real path under its real name — no `deleted/` subtree, no name
750/// decoration (ADR 0008 v2). The `deleted_ino` encoding keeps content served
751/// from the recovered-bytes cache via the existing `Deleted` namespace.
752fn deleted_in_place_children(entries: &[DeletedEntry], parent_fs_ino: u64) -> Vec<(u64, String)> {
753    entries
754        .iter()
755        .filter(|e| !e.orphan && e.parent_ino == Some(parent_fs_ino))
756        .map(|e| (deleted_ino(e.fs_ino), e.name.clone()))
757        .collect()
758}
759
760/// A [`crate::marking::Mark`] for a cached deleted entry — the adapter from this
761/// module's `DeletedEntry` onto the platform-agnostic marking schema, so the
762/// Unix xattr channel renders the exact same values the Windows ADS channel does.
763fn entry_mark(entry: &DeletedEntry) -> crate::marking::Mark {
764    crate::marking::Mark {
765        allocation: entry.allocation,
766        macb: crate::marking::Macb {
767            modified: entry.macb.modified.seconds,
768            accessed: entry.macb.accessed.seconds,
769            changed: entry.macb.changed.seconds,
770            born: entry.macb.born.seconds,
771        },
772    }
773}
774
775/// The out-of-band marking schema exposed on a recovered-deleted entry (ADR
776/// 0008 v2): the deleted/orphan status plus the recovered MACB times. Live
777/// files carry none of these — the xattr channel is the mount's red-X. The names
778/// and values are owned by [`crate::marking`], the single source of truth.
779fn deleted_xattr_names() -> &'static [&'static str] {
780    &crate::marking::UNIX_XATTR_NAMES
781}
782
783/// Value of one xattr on a recovered-deleted entry, or `None` when the name is
784/// not part of the schema (the getxattr shell then replies `ENODATA`).
785/// `user.4n6.status` is `deleted`|`orphan`; the `macb.*` values are ISO-8601
786/// UTC. This is a metadata-only channel — the recovered content is untouched.
787fn deleted_xattr_value(entry: &DeletedEntry, name: &str) -> Option<Vec<u8>> {
788    crate::marking::unix_xattr_value(&entry_mark(entry), name)
789}
790
791/// Copy-up base bytes for an in-place recovered-deleted entry: its recovered
792/// content, when readable. `None` when the entry is unknown or its content
793/// could not be recovered — a write then fails loud rather than fabricating an
794/// empty file. The recovered base in the cache stays untouched; the write lands
795/// on the COW overlay, exactly like a live file (ADR 0008 v2).
796fn deleted_cow_base(entries: &[DeletedEntry], fs_ino: u64) -> Option<Vec<u8>> {
797    entries
798        .iter()
799        .find(|e| e.fs_ino == fs_ino && e.readable)
800        .map(|e| e.data.clone())
801}
802
803impl Filesystem for ForensicFuseFs {
804    fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
805        let name_bytes = name.as_encoded_bytes();
806
807        // Virtual root (disk overlay): resolve virtual directory names. In Raw
808        // layout the root maps straight to the ForensicFs root (handled below).
809        if parent == FUSE_ROOT_INO && self.layout == crate::MountLayout::DiskOverlay {
810            for (ino, dir_name) in root_dir_listing(self.cache_orphans_present()) {
811                if name_bytes == dir_name.as_bytes() {
812                    let attr = if ino == FUSE_RW_INO && self.has_session() {
813                        let mut a = virtual_dir_attr(ino);
814                        a.perm = 0o755;
815                        a
816                    } else {
817                        virtual_dir_attr(ino)
818                    };
819                    reply.entry(&TTL, &attr, 0);
820                    return;
821                }
822            }
823            reply.error(libc::ENOENT);
824            return;
825        }
826
827        // $Orphans/ namespace lookup (top-level, ADR 0008 v2): the unplaceable
828        // recovered entries, disambiguated by mtime + record id.
829        if parent == FUSE_ORPHANS_INO {
830            self.ensure_deleted_cache();
831            let cache = self.deleted_cache.borrow();
832            if let Some(entries) = cache.as_ref() {
833                for entry in entries.iter().filter(|e| e.orphan) {
834                    if name_bytes == entry.name.as_bytes() {
835                        let fuse_ino = deleted_ino(entry.fs_ino);
836                        reply.entry(&TTL, &virtual_file_attr(fuse_ino, entry.size), 0);
837                        return;
838                    }
839                }
840            }
841            reply.error(libc::ENOENT);
842            return;
843        }
844
845        // journal/ namespace lookup.
846        if parent == FUSE_JOURNAL_INO {
847            self.ensure_journal_cache();
848            let cache = self.journal_cache.borrow();
849            if let Some(entries) = cache.as_ref() {
850                for entry in entries {
851                    if name_bytes == entry.name.as_bytes() {
852                        let fuse_ino = journal_ino(entry.sequence);
853                        let attr = virtual_dir_attr(fuse_ino);
854                        reply.entry(&TTL, &attr, 0);
855                        return;
856                    }
857                }
858            }
859            reply.error(libc::ENOENT);
860            return;
861        }
862
863        // metadata/ namespace lookup.
864        if parent == FUSE_METADATA_INO {
865            self.ensure_metadata_cache();
866            let cache = self.metadata_cache.borrow();
867            if let Some(mc) = cache.as_ref() {
868                if name_bytes == b"superblock.json" {
869                    let fuse_ino = metadata_ino(1);
870                    let attr = virtual_file_attr(fuse_ino, mc.superblock_json.len() as u64);
871                    reply.entry(&TTL, &attr, 0);
872                    return;
873                }
874                if name_bytes == b"timeline.jsonl" {
875                    let fuse_ino = metadata_ino(2);
876                    let attr = virtual_file_attr(fuse_ino, mc.timeline_jsonl.len() as u64);
877                    reply.entry(&TTL, &attr, 0);
878                    return;
879                }
880            }
881            reply.error(libc::ENOENT);
882            return;
883        }
884
885        // unallocated/ namespace lookup.
886        if parent == FUSE_UNALLOCATED_INO {
887            self.ensure_unallocated_cache();
888            let cache = self.unallocated_cache.borrow();
889            if let Some(entries) = cache.as_ref() {
890                for (i, entry) in entries.iter().enumerate() {
891                    if name_bytes == entry.name.as_bytes() {
892                        let fuse_ino = unallocated_ino(i as u64);
893                        let block_size = self.fs.borrow().block_size();
894                        let size = entry.length * block_size;
895                        let attr = virtual_file_attr(fuse_ino, size);
896                        reply.entry(&TTL, &attr, 0);
897                        return;
898                    }
899                }
900            }
901            reply.error(libc::ENOENT);
902            return;
903        }
904
905        // session/ namespace lookup.
906        if parent == FUSE_SESSION_INO {
907            if name_bytes == b"status.json" && self.has_session() {
908                let session = self.session.borrow();
909                let s = session.as_ref().unwrap();
910                let status = serde_json::json!({
911                    "image_path": s.metadata.image_path,
912                    "image_sha256": s.metadata.image_sha256,
913                    "created": s.metadata.created,
914                });
915                let data = serde_json::to_string_pretty(&status)
916                    .unwrap_or_default()
917                    .into_bytes();
918                let fuse_ino = metadata_ino(100);
919                let attr = virtual_file_attr(fuse_ino, data.len() as u64);
920                reply.entry(&TTL, &attr, 0);
921                return;
922            }
923            reply.error(libc::ENOENT);
924            return;
925        }
926
927        // rw/ namespace lookup.
928        if let Some(fs_parent) = self.rw_parent_to_fs(parent) {
929            // ADR 0008 v2: an in-place recovered deleted child resolves at its
930            // real name. Precomputed before any fs borrow below.
931            let deleted_hit = self.lookup_deleted_in_place(fs_parent, name_bytes);
932
933            // Check overlay created files first.
934            if let Some((id, counter, is_dir)) = self.find_created_by_name(fs_parent, name_bytes) {
935                let session = self.session.borrow();
936                let session = session.as_ref().unwrap();
937                let entry = if is_dir {
938                    session.overlay.dirs.get(&id)
939                } else {
940                    session.overlay.created.get(&id)
941                };
942                if let Some(entry) = entry {
943                    let fuse_ino = rw_ino(counter + 9_000_000);
944                    let attr = Self::overlay_created_attr(fuse_ino, entry.size, is_dir);
945                    reply.entry(&TTL, &attr, 0);
946                    return;
947                }
948            }
949
950            // Check if name is a modified file.
951            {
952                let mut fs = self.fs.borrow_mut();
953                match fs.lookup(fs_parent, name_bytes) {
954                    Ok(Some(child_ino)) => {
955                        // Check whiteout.
956                        if self.is_whiteout(child_ino) {
957                            reply.error(libc::ENOENT);
958                            return;
959                        }
960
961                        // Check if modified in overlay.
962                        let session = self.session.borrow();
963                        let overlay_id = Self::modified_overlay_id(child_ino);
964                        if let Some(s) = session.as_ref() {
965                            if s.overlay.modified.contains_key(&child_ino) {
966                                if let Ok(meta) = fs.metadata(child_ino) {
967                                    let fuse_ino = rw_ino(child_ino);
968                                    let mut attr = fs_to_attr(fuse_ino, &meta);
969                                    if let Ok(data) = s.read_overlay_file(&overlay_id) {
970                                        attr.size = data.len() as u64;
971                                        attr.blocks = attr.size.div_ceil(512);
972                                    }
973                                    reply.entry(&TTL, &attr, 0);
974                                    return;
975                                }
976                                reply.error(libc::EIO);
977                                return;
978                            }
979                        }
980
981                        // Not modified, return attrs under rw/ namespace.
982                        match fs.metadata(child_ino) {
983                            Ok(meta) => {
984                                let fuse_ino = rw_ino(child_ino);
985                                reply.entry(&TTL, &fs_to_attr(fuse_ino, &meta), 0);
986                            }
987                            Err(_) => reply.error(libc::EIO),
988                        }
989                        return;
990                    }
991                    Ok(None) => {
992                        if let Some((fino, size)) = deleted_hit {
993                            reply.entry(&TTL, &virtual_file_attr(fino, size), 0);
994                            return;
995                        }
996                        reply.error(libc::ENOENT);
997                        return;
998                    }
999                    Err(_) => {
1000                        reply.error(libc::EIO);
1001                        return;
1002                    }
1003                }
1004            }
1005        }
1006
1007        // ro/ namespace: the ro/ virtual dir maps to the fs root inode. In Raw
1008        // layout the FUSE root itself maps to the fs root (no ro/ wrapper).
1009        let fs_parent = match parent {
1010            FUSE_RO_INO => self.root_ino,
1011            FUSE_ROOT_INO if self.layout == crate::MountLayout::Raw => self.root_ino,
1012            _ => {
1013                if let InodeNamespace::Ro(ino) = decode_fuse_ino(parent) {
1014                    ino
1015                } else {
1016                    reply.error(libc::ENOENT);
1017                    return;
1018                }
1019            }
1020        };
1021
1022        // In-place recovered deleted child (checked before the fs borrow).
1023        let deleted_hit = self.lookup_deleted_in_place(fs_parent, name_bytes);
1024
1025        let mut fs = self.fs.borrow_mut();
1026        match fs.lookup(fs_parent, name_bytes) {
1027            Ok(Some(child_ino)) => match fs.metadata(child_ino) {
1028                Ok(meta) => {
1029                    let fuse_ino = ro_ino(child_ino);
1030                    reply.entry(&TTL, &fs_to_attr(fuse_ino, &meta), 0);
1031                }
1032                Err(_) => reply.error(libc::EIO),
1033            },
1034            Ok(None) => {
1035                if let Some((fino, size)) = deleted_hit {
1036                    reply.entry(&TTL, &virtual_file_attr(fino, size), 0);
1037                } else {
1038                    reply.error(libc::ENOENT);
1039                }
1040            }
1041            Err(_) => reply.error(libc::EIO),
1042        }
1043    }
1044
1045    fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
1046        // Virtual root.
1047        if ino == FUSE_ROOT_INO {
1048            reply.attr(&TTL, &virtual_dir_attr(FUSE_ROOT_INO));
1049            return;
1050        }
1051
1052        // Virtual top-level directories (incl. the top-level `$Orphans/`).
1053        if (FUSE_RO_INO..=FUSE_SESSION_INO).contains(&ino) || ino == FUSE_ORPHANS_INO {
1054            let mut attr = virtual_dir_attr(ino);
1055            if ino == FUSE_RW_INO && self.has_session() {
1056                attr.perm = 0o755;
1057            }
1058            reply.attr(&TTL, &attr);
1059            return;
1060        }
1061
1062        match decode_fuse_ino(ino) {
1063            InodeNamespace::Deleted(fs_ino) => {
1064                self.ensure_deleted_cache();
1065                let cache = self.deleted_cache.borrow();
1066                if let Some(entries) = cache.as_ref() {
1067                    if let Some(entry) = entries.iter().find(|e| e.fs_ino == fs_ino) {
1068                        // A copied-up delete reports its overlay size (ADR 0008
1069                        // v2 COW); otherwise the recovered size.
1070                        let mut size = entry.size;
1071                        let session = self.session.borrow();
1072                        if let Some(s) = session.as_ref() {
1073                            if s.overlay.modified.contains_key(&fs_ino) {
1074                                let overlay_id = Self::modified_overlay_id(fs_ino);
1075                                if let Ok(d) = s.read_overlay_file(&overlay_id) {
1076                                    size = d.len() as u64;
1077                                }
1078                            }
1079                        }
1080                        reply.attr(&TTL, &virtual_file_attr(ino, size));
1081                        return;
1082                    }
1083                }
1084                reply.error(libc::ENOENT);
1085            }
1086            InodeNamespace::Metadata(id) => {
1087                self.ensure_metadata_cache();
1088                let cache = self.metadata_cache.borrow();
1089                if let Some(mc) = cache.as_ref() {
1090                    match id {
1091                        1 => {
1092                            reply.attr(
1093                                &TTL,
1094                                &virtual_file_attr(ino, mc.superblock_json.len() as u64),
1095                            );
1096                        }
1097                        2 => {
1098                            reply.attr(
1099                                &TTL,
1100                                &virtual_file_attr(ino, mc.timeline_jsonl.len() as u64),
1101                            );
1102                        }
1103                        100 => {
1104                            // session/status.json
1105                            if self.has_session() {
1106                                let session = self.session.borrow();
1107                                let s = session.as_ref().unwrap();
1108                                let status = serde_json::json!({
1109                                    "image_path": s.metadata.image_path,
1110                                    "image_sha256": s.metadata.image_sha256,
1111                                    "created": s.metadata.created,
1112                                });
1113                                let data =
1114                                    serde_json::to_string_pretty(&status).unwrap_or_default();
1115                                reply.attr(&TTL, &virtual_file_attr(ino, data.len() as u64));
1116                            } else {
1117                                reply.error(libc::ENOENT);
1118                            }
1119                        }
1120                        _ => reply.error(libc::ENOENT),
1121                    }
1122                } else {
1123                    reply.error(libc::ENOENT);
1124                }
1125            }
1126            InodeNamespace::Journal(seq) => {
1127                self.ensure_journal_cache();
1128                let cache = self.journal_cache.borrow();
1129                if let Some(entries) = cache.as_ref() {
1130                    if entries.iter().any(|e| e.sequence == seq) {
1131                        reply.attr(&TTL, &virtual_dir_attr(ino));
1132                    } else {
1133                        reply.error(libc::ENOENT);
1134                    }
1135                } else {
1136                    reply.error(libc::ENOENT);
1137                }
1138            }
1139            InodeNamespace::Unallocated(range_id) => {
1140                self.ensure_unallocated_cache();
1141                let cache = self.unallocated_cache.borrow();
1142                if let Some(entries) = cache.as_ref() {
1143                    if let Some(entry) = entries.get(range_id as usize) {
1144                        let block_size = self.fs.borrow().block_size();
1145                        let size = entry.length * block_size;
1146                        reply.attr(&TTL, &virtual_file_attr(ino, size));
1147                    } else {
1148                        reply.error(libc::ENOENT);
1149                    }
1150                } else {
1151                    reply.error(libc::ENOENT);
1152                }
1153            }
1154            InodeNamespace::Ro(fs_ino) => {
1155                let mut fs = self.fs.borrow_mut();
1156                match fs.metadata(fs_ino) {
1157                    Ok(meta) => reply.attr(&TTL, &fs_to_attr(ino, &meta)),
1158                    Err(_) => reply.error(libc::EIO),
1159                }
1160            }
1161            InodeNamespace::Rw(rw_id) => {
1162                // Check if this is a created overlay file (counter + 9_000_000).
1163                if rw_id >= 9_000_000 {
1164                    let counter = rw_id - 9_000_000;
1165                    let created_id = Self::created_overlay_id(counter);
1166                    let session = self.session.borrow();
1167                    if let Some(s) = session.as_ref() {
1168                        if let Some(entry) = s.overlay.created.get(&created_id) {
1169                            let attr = Self::overlay_created_attr(ino, entry.size, false);
1170                            reply.attr(&TTL, &attr);
1171                            return;
1172                        }
1173                        if let Some(entry) = s.overlay.dirs.get(&created_id) {
1174                            let attr = Self::overlay_created_attr(ino, entry.size, true);
1175                            reply.attr(&TTL, &attr);
1176                            return;
1177                        }
1178                    }
1179                    reply.error(libc::ENOENT);
1180                    return;
1181                }
1182
1183                // This is an fs inode viewed through rw/.
1184                let fs_ino = rw_id;
1185                let mut fs = self.fs.borrow_mut();
1186                match fs.metadata(fs_ino) {
1187                    Ok(meta) => {
1188                        let mut attr = fs_to_attr(ino, &meta);
1189                        // If modified, update size from overlay.
1190                        let session = self.session.borrow();
1191                        if let Some(s) = session.as_ref() {
1192                            let overlay_id = Self::modified_overlay_id(fs_ino);
1193                            if s.overlay.modified.contains_key(&fs_ino) {
1194                                if let Ok(data) = s.read_overlay_file(&overlay_id) {
1195                                    attr.size = data.len() as u64;
1196                                    attr.blocks = attr.size.div_ceil(512);
1197                                }
1198                            }
1199                        }
1200                        reply.attr(&TTL, &attr);
1201                    }
1202                    Err(_) => reply.error(libc::EIO),
1203                }
1204            }
1205            _ => reply.error(libc::ENOENT),
1206        }
1207    }
1208
1209    /// Read one extended attribute. Only recovered-deleted/orphan entries carry
1210    /// the `user.4n6.*` marking (ADR 0008 v2); live files and virtual nodes have
1211    /// none, so they reply `ENODATA`. Follows the FUSE size-probe protocol:
1212    /// `size == 0` returns the value length; otherwise the bytes (or `ERANGE`).
1213    fn getxattr(&mut self, _req: &Request, ino: u64, name: &OsStr, size: u32, reply: ReplyXattr) {
1214        let value = if let InodeNamespace::Deleted(fs_ino) = decode_fuse_ino(ino) {
1215            self.ensure_deleted_cache();
1216            let cache = self.deleted_cache.borrow();
1217            let attr_name = name.to_str();
1218            cache.as_ref().and_then(|entries| {
1219                let n = attr_name?;
1220                let e = entries.iter().find(|e| e.fs_ino == fs_ino)?;
1221                deleted_xattr_value(e, n)
1222            })
1223        } else {
1224            None
1225        };
1226        match value {
1227            Some(v) => {
1228                if size == 0 {
1229                    reply.size(v.len() as u32);
1230                } else if (v.len() as u32) <= size {
1231                    reply.data(&v);
1232                } else {
1233                    reply.error(libc::ERANGE);
1234                }
1235            }
1236            None => reply.error(libc::ENODATA),
1237        }
1238    }
1239
1240    /// List the extended attribute names on an entry. Recovered-deleted/orphan
1241    /// entries expose the `user.4n6.*` marking schema; everything else lists
1242    /// nothing. Names are NUL-separated per the FUSE `listxattr` contract.
1243    fn listxattr(&mut self, _req: &Request, ino: u64, size: u32, reply: ReplyXattr) {
1244        let mut buf: Vec<u8> = Vec::new();
1245        if let InodeNamespace::Deleted(fs_ino) = decode_fuse_ino(ino) {
1246            self.ensure_deleted_cache();
1247            let cache = self.deleted_cache.borrow();
1248            let present = cache
1249                .as_ref()
1250                .is_some_and(|entries| entries.iter().any(|e| e.fs_ino == fs_ino));
1251            if present {
1252                for n in deleted_xattr_names() {
1253                    buf.extend_from_slice(n.as_bytes());
1254                    buf.push(0);
1255                }
1256            }
1257        }
1258        if size == 0 {
1259            reply.size(buf.len() as u32);
1260        } else if (buf.len() as u32) <= size {
1261            reply.data(&buf);
1262        } else {
1263            reply.error(libc::ERANGE);
1264        }
1265    }
1266
1267    fn readdir(
1268        &mut self,
1269        _req: &Request,
1270        ino: u64,
1271        _fh: u64,
1272        offset: i64,
1273        mut reply: ReplyDirectory,
1274    ) {
1275        let offset = offset as usize;
1276
1277        // Root directory: virtual dirs (DiskOverlay) or the ForensicFs tree
1278        // directly (Raw) — both via the tested root_children() decision.
1279        if ino == FUSE_ROOT_INO {
1280            let has_orphans = self.cache_orphans_present();
1281            let mut entries: Vec<(u64, FileType, String)> = vec![
1282                (FUSE_ROOT_INO, FileType::Directory, ".".to_string()),
1283                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1284            ];
1285            {
1286                let mut fs = self.fs.borrow_mut();
1287                let Ok(children) =
1288                    root_children(self.layout, &mut **fs, self.root_ino, has_orphans)
1289                else {
1290                    reply.error(libc::EIO);
1291                    return;
1292                };
1293                for (fino, name, kind) in children {
1294                    entries.push((fino, kind, String::from_utf8_lossy(&name).into_owned()));
1295                }
1296            }
1297            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1298                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1299                    break;
1300                }
1301            }
1302            reply.ok();
1303            return;
1304        }
1305
1306        // rw/ namespace readdir.
1307        if let Some(fs_dir_ino) = match ino {
1308            FUSE_RW_INO => Some(self.root_ino),
1309            _ => match decode_fuse_ino(ino) {
1310                InodeNamespace::Rw(rw_id) if rw_id < 9_000_000 => Some(rw_id),
1311                _ => None,
1312            },
1313        } {
1314            // ADR 0008 v2: recovered deleted children render in-place beside the
1315            // live siblings. Computed before borrowing fs (ensure_deleted_cache
1316            // takes its own fs borrow).
1317            self.ensure_deleted_cache();
1318            let injected: Vec<(u64, FileType, String)> = {
1319                let cache = self.deleted_cache.borrow();
1320                cache.as_ref().map_or_else(Vec::new, |c| {
1321                    deleted_in_place_children(c, fs_dir_ino)
1322                        .into_iter()
1323                        .map(|(dino, name)| (dino, FileType::RegularFile, name))
1324                        .collect()
1325                })
1326            };
1327            let mut fs = self.fs.borrow_mut();
1328            match fs.read_dir(fs_dir_ino) {
1329                Ok(entries) => {
1330                    let session = self.session.borrow();
1331                    let mut fuse_entries: Vec<(u64, FileType, String)> = Vec::new();
1332
1333                    for e in &entries {
1334                        let name = e.name_str();
1335                        let child_ino = e.inode;
1336
1337                        // Filter out whiteouts.
1338                        if let Some(s) = session.as_ref() {
1339                            if name != "." && name != ".." && s.overlay.deleted.contains(&child_ino)
1340                            {
1341                                continue;
1342                            }
1343                        }
1344
1345                        let fuse_ino = if name == "." || name == ".." {
1346                            if fs_dir_ino == self.root_ino && name == "." {
1347                                FUSE_RW_INO
1348                            } else if fs_dir_ino == self.root_ino && name == ".." {
1349                                FUSE_ROOT_INO
1350                            } else {
1351                                rw_ino(child_ino)
1352                            }
1353                        } else {
1354                            rw_ino(child_ino)
1355                        };
1356                        let kind = fs_file_type_to_fuse(e.file_type);
1357                        fuse_entries.push((fuse_ino, kind, name));
1358                    }
1359
1360                    // Add overlay created entries for this directory.
1361                    if let Some(s) = session.as_ref() {
1362                        for (id, entry) in &s.overlay.created {
1363                            if entry.parent_ino == fs_dir_ino {
1364                                if let Some(counter) =
1365                                    id.strip_prefix("new_").and_then(|s| s.parse::<u64>().ok())
1366                                {
1367                                    let fuse_ino = rw_ino(counter + 9_000_000);
1368                                    fuse_entries.push((
1369                                        fuse_ino,
1370                                        FileType::RegularFile,
1371                                        entry.name.clone(),
1372                                    ));
1373                                }
1374                            }
1375                        }
1376                        for (id, entry) in &s.overlay.dirs {
1377                            if entry.parent_ino == fs_dir_ino {
1378                                if let Some(counter) =
1379                                    id.strip_prefix("new_").and_then(|s| s.parse::<u64>().ok())
1380                                {
1381                                    let fuse_ino = rw_ino(counter + 9_000_000);
1382                                    fuse_entries.push((
1383                                        fuse_ino,
1384                                        FileType::Directory,
1385                                        entry.name.clone(),
1386                                    ));
1387                                }
1388                            }
1389                        }
1390                    }
1391
1392                    // Recovered deleted children, in-place at their real name.
1393                    fuse_entries.extend(injected);
1394
1395                    for (i, (entry_ino, kind, name)) in fuse_entries.iter().enumerate().skip(offset)
1396                    {
1397                        if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1398                            break;
1399                        }
1400                    }
1401                    reply.ok();
1402                }
1403                Err(_) => reply.error(libc::EIO),
1404            }
1405            return;
1406        }
1407
1408        // $Orphans/ readdir (top-level, ADR 0008 v2): the unplaceable recovered
1409        // entries (true orphans, live-name collisions, older same-name
1410        // deletes), disambiguated by mtime + record id.
1411        if ino == FUSE_ORPHANS_INO {
1412            self.ensure_deleted_cache();
1413            let cache = self.deleted_cache.borrow();
1414            let mut entries: Vec<(u64, FileType, String)> = vec![
1415                (FUSE_ORPHANS_INO, FileType::Directory, ".".to_string()),
1416                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1417            ];
1418            if let Some(cached) = cache.as_ref() {
1419                for entry in cached.iter().filter(|e| e.orphan) {
1420                    entries.push((
1421                        deleted_ino(entry.fs_ino),
1422                        FileType::RegularFile,
1423                        entry.name.clone(),
1424                    ));
1425                }
1426            }
1427            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1428                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1429                    break;
1430                }
1431            }
1432            reply.ok();
1433            return;
1434        }
1435
1436        // journal/ readdir
1437        if ino == FUSE_JOURNAL_INO {
1438            self.ensure_journal_cache();
1439            let cache = self.journal_cache.borrow();
1440            let mut entries: Vec<(u64, FileType, String)> = vec![
1441                (FUSE_JOURNAL_INO, FileType::Directory, ".".to_string()),
1442                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1443            ];
1444            if let Some(cached) = cache.as_ref() {
1445                for entry in cached {
1446                    entries.push((
1447                        journal_ino(entry.sequence),
1448                        FileType::Directory,
1449                        entry.name.clone(),
1450                    ));
1451                }
1452            }
1453            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1454                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1455                    break;
1456                }
1457            }
1458            reply.ok();
1459            return;
1460        }
1461
1462        // journal/txn_N/ readdir (empty directory for now)
1463        if let InodeNamespace::Journal(_seq) = decode_fuse_ino(ino) {
1464            if offset == 0 {
1465                let _ = reply.add(ino, 1, FileType::Directory, ".");
1466                let _ = reply.add(FUSE_JOURNAL_INO, 2, FileType::Directory, "..");
1467            }
1468            reply.ok();
1469            return;
1470        }
1471
1472        // metadata/ readdir
1473        if ino == FUSE_METADATA_INO {
1474            self.ensure_metadata_cache();
1475            let cache = self.metadata_cache.borrow();
1476            let mut entries: Vec<(u64, FileType, String)> = vec![
1477                (FUSE_METADATA_INO, FileType::Directory, ".".to_string()),
1478                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1479            ];
1480            if cache.is_some() {
1481                entries.push((
1482                    metadata_ino(1),
1483                    FileType::RegularFile,
1484                    "superblock.json".to_string(),
1485                ));
1486                entries.push((
1487                    metadata_ino(2),
1488                    FileType::RegularFile,
1489                    "timeline.jsonl".to_string(),
1490                ));
1491            }
1492            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1493                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1494                    break;
1495                }
1496            }
1497            reply.ok();
1498            return;
1499        }
1500
1501        // unallocated/ readdir
1502        if ino == FUSE_UNALLOCATED_INO {
1503            self.ensure_unallocated_cache();
1504            let cache = self.unallocated_cache.borrow();
1505            let mut entries: Vec<(u64, FileType, String)> = vec![
1506                (FUSE_UNALLOCATED_INO, FileType::Directory, ".".to_string()),
1507                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1508            ];
1509            if let Some(cached) = cache.as_ref() {
1510                for (i, entry) in cached.iter().enumerate() {
1511                    entries.push((
1512                        unallocated_ino(i as u64),
1513                        FileType::RegularFile,
1514                        entry.name.clone(),
1515                    ));
1516                }
1517            }
1518            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1519                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1520                    break;
1521                }
1522            }
1523            reply.ok();
1524            return;
1525        }
1526
1527        // session/ readdir
1528        if ino == FUSE_SESSION_INO {
1529            let mut entries: Vec<(u64, FileType, String)> = vec![
1530                (FUSE_SESSION_INO, FileType::Directory, ".".to_string()),
1531                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1532            ];
1533            if self.has_session() {
1534                entries.push((
1535                    metadata_ino(100),
1536                    FileType::RegularFile,
1537                    "status.json".to_string(),
1538                ));
1539            }
1540            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1541                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1542                    break;
1543                }
1544            }
1545            reply.ok();
1546            return;
1547        }
1548
1549        // Determine the fs inode for this directory.
1550        let fs_dir_ino = match ino {
1551            FUSE_RO_INO => self.root_ino,
1552            _ => {
1553                if let InodeNamespace::Ro(fs_ino) = decode_fuse_ino(ino) {
1554                    fs_ino
1555                } else {
1556                    reply.error(libc::ENOENT);
1557                    return;
1558                }
1559            }
1560        };
1561
1562        // ADR 0008 v2: recovered deleted children also render in-place in the
1563        // read-only view of the main tree.
1564        self.ensure_deleted_cache();
1565        let injected: Vec<(u64, FileType, String)> = {
1566            let cache = self.deleted_cache.borrow();
1567            cache.as_ref().map_or_else(Vec::new, |c| {
1568                deleted_in_place_children(c, fs_dir_ino)
1569                    .into_iter()
1570                    .map(|(dino, name)| (dino, FileType::RegularFile, name))
1571                    .collect()
1572            })
1573        };
1574
1575        let mut fs = self.fs.borrow_mut();
1576        match fs.read_dir(fs_dir_ino) {
1577            Ok(entries) => {
1578                let root_ino = self.root_ino;
1579                let mut fuse_entries: Vec<(u64, FileType, String)> = entries
1580                    .iter()
1581                    .map(|e| {
1582                        let name = e.name_str();
1583                        let fuse_ino = if name == "." || name == ".." {
1584                            if fs_dir_ino == root_ino && name == "." {
1585                                FUSE_RO_INO
1586                            } else if fs_dir_ino == root_ino && name == ".." {
1587                                FUSE_ROOT_INO
1588                            } else {
1589                                ro_ino(e.inode)
1590                            }
1591                        } else {
1592                            ro_ino(e.inode)
1593                        };
1594                        let kind = fs_file_type_to_fuse(e.file_type);
1595                        (fuse_ino, kind, name)
1596                    })
1597                    .collect();
1598
1599                fuse_entries.extend(injected);
1600
1601                for (i, (entry_ino, kind, name)) in fuse_entries.iter().enumerate().skip(offset) {
1602                    if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1603                        break;
1604                    }
1605                }
1606                reply.ok();
1607            }
1608            Err(_) => reply.error(libc::EIO),
1609        }
1610    }
1611
1612    fn read(
1613        &mut self,
1614        _req: &Request,
1615        ino: u64,
1616        _fh: u64,
1617        offset: i64,
1618        size: u32,
1619        _flags: i32,
1620        _lock_owner: Option<u64>,
1621        reply: ReplyData,
1622    ) {
1623        match decode_fuse_ino(ino) {
1624            InodeNamespace::Deleted(fs_ino) => {
1625                // A copied-up recovered-deleted file reads from the overlay
1626                // (ADR 0008 v2 COW); otherwise from the recovered-bytes cache.
1627                {
1628                    let session = self.session.borrow();
1629                    if let Some(s) = session.as_ref() {
1630                        if s.overlay.modified.contains_key(&fs_ino) {
1631                            let overlay_id = Self::modified_overlay_id(fs_ino);
1632                            if let Ok(d) = s.read_overlay_file(&overlay_id) {
1633                                let off = offset as usize;
1634                                if off >= d.len() {
1635                                    reply.data(&[]);
1636                                } else {
1637                                    let end = (off + size as usize).min(d.len());
1638                                    reply.data(&d[off..end]);
1639                                }
1640                                return;
1641                            }
1642                        }
1643                    }
1644                }
1645                self.ensure_deleted_cache();
1646                let cache = self.deleted_cache.borrow();
1647                if let Some(entries) = cache.as_ref() {
1648                    if let Some(entry) = entries.iter().find(|e| e.fs_ino == fs_ino) {
1649                        // Unreadable recovered content is a loud read error, not
1650                        // a fabricated 0-byte success.
1651                        if !entry.readable {
1652                            reply.error(libc::EIO);
1653                            return;
1654                        }
1655                        let off = offset as usize;
1656                        if off >= entry.data.len() {
1657                            reply.data(&[]);
1658                        } else {
1659                            let end = (off + size as usize).min(entry.data.len());
1660                            reply.data(&entry.data[off..end]);
1661                        }
1662                        return;
1663                    }
1664                }
1665                reply.error(libc::ENOENT);
1666            }
1667            InodeNamespace::Metadata(id) => {
1668                self.ensure_metadata_cache();
1669                let data = match id {
1670                    1 => {
1671                        let cache = self.metadata_cache.borrow();
1672                        cache.as_ref().map(|mc| mc.superblock_json.clone())
1673                    }
1674                    2 => {
1675                        let cache = self.metadata_cache.borrow();
1676                        cache.as_ref().map(|mc| mc.timeline_jsonl.clone())
1677                    }
1678                    100 => {
1679                        // session/status.json
1680                        let session = self.session.borrow();
1681                        session.as_ref().map(|s| {
1682                            let status = serde_json::json!({
1683                                "image_path": s.metadata.image_path,
1684                                "image_sha256": s.metadata.image_sha256,
1685                                "created": s.metadata.created,
1686                            });
1687                            serde_json::to_string_pretty(&status)
1688                                .unwrap_or_default()
1689                                .into_bytes()
1690                        })
1691                    }
1692                    _ => None,
1693                };
1694                match data {
1695                    Some(buf) => {
1696                        let off = offset as usize;
1697                        if off >= buf.len() {
1698                            reply.data(&[]);
1699                        } else {
1700                            let end = (off + size as usize).min(buf.len());
1701                            reply.data(&buf[off..end]);
1702                        }
1703                    }
1704                    None => reply.error(libc::ENOENT),
1705                }
1706            }
1707            InodeNamespace::Unallocated(range_id) => {
1708                self.ensure_unallocated_cache();
1709                let range_info = {
1710                    let cache = self.unallocated_cache.borrow();
1711                    cache.as_ref().and_then(|entries| {
1712                        entries.get(range_id as usize).map(|e| FsBlockRange {
1713                            start: e.start,
1714                            length: e.length,
1715                        })
1716                    })
1717                };
1718                match range_info {
1719                    Some(range) => {
1720                        let mut fs = self.fs.borrow_mut();
1721                        match fs.read_unallocated(&range) {
1722                            Ok(data) => {
1723                                let off = offset as usize;
1724                                if off >= data.len() {
1725                                    reply.data(&[]);
1726                                } else {
1727                                    let end = (off + size as usize).min(data.len());
1728                                    reply.data(&data[off..end]);
1729                                }
1730                            }
1731                            Err(_) => reply.error(libc::EIO),
1732                        }
1733                    }
1734                    None => reply.error(libc::ENOENT),
1735                }
1736            }
1737            InodeNamespace::Ro(fs_ino) => {
1738                let mut fs = self.fs.borrow_mut();
1739                match fs.read_file_range(fs_ino, offset as u64, u64::from(size)) {
1740                    Ok(data) => reply.data(&data),
1741                    Err(_) => reply.error(libc::EIO),
1742                }
1743            }
1744            InodeNamespace::Rw(rw_id) => {
1745                // Check if this is a created overlay file.
1746                if rw_id >= 9_000_000 {
1747                    let counter = rw_id - 9_000_000;
1748                    let created_id = Self::created_overlay_id(counter);
1749                    let session = self.session.borrow();
1750                    if let Some(s) = session.as_ref() {
1751                        if s.overlay.created.contains_key(&created_id)
1752                            || s.overlay.dirs.contains_key(&created_id)
1753                        {
1754                            if let Ok(data) = s.read_overlay_file(&created_id) {
1755                                let off = offset as usize;
1756                                let end = (off + size as usize).min(data.len());
1757                                if off >= data.len() {
1758                                    reply.data(&[]);
1759                                } else {
1760                                    reply.data(&data[off..end]);
1761                                }
1762                                return;
1763                            }
1764                            reply.error(libc::EIO);
1765                            return;
1766                        }
1767                    }
1768                    reply.error(libc::ENOENT);
1769                    return;
1770                }
1771
1772                // fs inode under rw/.
1773                let fs_ino = rw_id;
1774                // Check if modified in overlay.
1775                let session = self.session.borrow();
1776                if let Some(s) = session.as_ref() {
1777                    let overlay_id = Self::modified_overlay_id(fs_ino);
1778                    if s.overlay.modified.contains_key(&fs_ino) {
1779                        if let Ok(data) = s.read_overlay_file(&overlay_id) {
1780                            let off = offset as usize;
1781                            let end = (off + size as usize).min(data.len());
1782                            if off >= data.len() {
1783                                reply.data(&[]);
1784                            } else {
1785                                reply.data(&data[off..end]);
1786                            }
1787                            return;
1788                        }
1789                        reply.error(libc::EIO);
1790                        return;
1791                    }
1792                }
1793                drop(session);
1794
1795                // Fall back to underlying fs.
1796                let mut fs = self.fs.borrow_mut();
1797                match fs.read_file_range(fs_ino, offset as u64, u64::from(size)) {
1798                    Ok(data) => reply.data(&data),
1799                    Err(_) => reply.error(libc::EIO),
1800                }
1801            }
1802            _ => {
1803                reply.error(libc::ENOENT);
1804            }
1805        }
1806    }
1807
1808    fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) {
1809        let fs_ino = match decode_fuse_ino(ino) {
1810            InodeNamespace::Ro(fs_ino) => fs_ino,
1811            InodeNamespace::Rw(rw_id) if rw_id < 9_000_000 => rw_id,
1812            _ => {
1813                reply.error(libc::ENOENT);
1814                return;
1815            }
1816        };
1817
1818        let mut fs = self.fs.borrow_mut();
1819        match fs.read_link(fs_ino) {
1820            Ok(target) => reply.data(&target),
1821            Err(_) => reply.error(libc::EIO),
1822        }
1823    }
1824
1825    fn write(
1826        &mut self,
1827        _req: &Request,
1828        ino: u64,
1829        _fh: u64,
1830        offset: i64,
1831        data: &[u8],
1832        _write_flags: u32,
1833        _flags: i32,
1834        _lock_owner: Option<u64>,
1835        reply: ReplyWrite,
1836    ) {
1837        if !self.has_session() {
1838            reply.error(libc::EROFS);
1839            return;
1840        }
1841
1842        match decode_fuse_ino(ino) {
1843            InodeNamespace::Rw(rw_id) => {
1844                // Created overlay file.
1845                if rw_id >= 9_000_000 {
1846                    let counter = rw_id - 9_000_000;
1847                    let created_id = Self::created_overlay_id(counter);
1848                    let mut session = self.session.borrow_mut();
1849                    let s = session.as_mut().unwrap();
1850
1851                    let is_known = s.overlay.created.contains_key(&created_id)
1852                        || s.overlay.dirs.contains_key(&created_id);
1853                    if !is_known {
1854                        reply.error(libc::ENOENT);
1855                        return;
1856                    }
1857
1858                    let mut buf = s.read_overlay_file(&created_id).unwrap_or_default();
1859                    let off = offset as usize;
1860                    let end = off + data.len();
1861                    if end > buf.len() {
1862                        buf.resize(end, 0);
1863                    }
1864                    buf[off..end].copy_from_slice(data);
1865
1866                    if s.write_overlay_file(&created_id, &buf).is_err() {
1867                        reply.error(libc::EIO);
1868                        return;
1869                    }
1870
1871                    if let Some(entry) = s.overlay.created.get_mut(&created_id) {
1872                        entry.size = buf.len() as u64;
1873                    }
1874                    if let Some(entry) = s.overlay.dirs.get_mut(&created_id) {
1875                        entry.size = buf.len() as u64;
1876                    }
1877
1878                    if s.save().is_err() {
1879                        reply.error(libc::EIO);
1880                        return;
1881                    }
1882
1883                    reply.written(data.len() as u32);
1884                    return;
1885                }
1886
1887                // Existing fs inode under rw/ — COW on first write.
1888                let fs_ino = rw_id;
1889                let overlay_id = Self::modified_overlay_id(fs_ino);
1890
1891                let mut session = self.session.borrow_mut();
1892                let s = session.as_mut().unwrap();
1893
1894                if !s.overlay.modified.contains_key(&fs_ino) {
1895                    let mut fs = self.fs.borrow_mut();
1896                    let Ok(original) = fs.read_file(fs_ino) else {
1897                        reply.error(libc::EIO);
1898                        return;
1899                    };
1900                    if s.write_overlay_file(&overlay_id, &original).is_err() {
1901                        reply.error(libc::EIO);
1902                        return;
1903                    }
1904                    s.overlay.modified.insert(fs_ino, overlay_id.clone());
1905                }
1906
1907                let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
1908                let off = offset as usize;
1909                let end = off + data.len();
1910                if end > buf.len() {
1911                    buf.resize(end, 0);
1912                }
1913                buf[off..end].copy_from_slice(data);
1914
1915                if s.write_overlay_file(&overlay_id, &buf).is_err() {
1916                    reply.error(libc::EIO);
1917                    return;
1918                }
1919
1920                if s.save().is_err() {
1921                    reply.error(libc::EIO);
1922                    return;
1923                }
1924
1925                reply.written(data.len() as u32);
1926            }
1927            // ADR 0008 v2: an in-place recovered-deleted file is COW-writable
1928            // like a live file. First write copies up its recovered bytes onto
1929            // the overlay (keyed by fs inode); the recovered base is untouched.
1930            InodeNamespace::Deleted(fs_ino) => {
1931                self.ensure_deleted_cache();
1932                let overlay_id = Self::modified_overlay_id(fs_ino);
1933                let mut session = self.session.borrow_mut();
1934                let s = session.as_mut().unwrap();
1935
1936                if !s.overlay.modified.contains_key(&fs_ino) {
1937                    let base = {
1938                        let cache = self.deleted_cache.borrow();
1939                        cache.as_ref().and_then(|e| deleted_cow_base(e, fs_ino))
1940                    };
1941                    // Unreadable recovered content cannot be copied up — fail
1942                    // loud, never fabricate an empty base.
1943                    let Some(base) = base else {
1944                        reply.error(libc::EIO);
1945                        return;
1946                    };
1947                    if s.write_overlay_file(&overlay_id, &base).is_err() {
1948                        reply.error(libc::EIO);
1949                        return;
1950                    }
1951                    s.overlay.modified.insert(fs_ino, overlay_id.clone());
1952                }
1953
1954                let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
1955                let off = offset as usize;
1956                let end = off + data.len();
1957                if end > buf.len() {
1958                    buf.resize(end, 0);
1959                }
1960                buf[off..end].copy_from_slice(data);
1961
1962                if s.write_overlay_file(&overlay_id, &buf).is_err() {
1963                    reply.error(libc::EIO);
1964                    return;
1965                }
1966                if s.save().is_err() {
1967                    reply.error(libc::EIO);
1968                    return;
1969                }
1970                reply.written(data.len() as u32);
1971            }
1972            _ => reply.error(libc::EROFS),
1973        }
1974    }
1975
1976    fn create(
1977        &mut self,
1978        _req: &Request,
1979        parent: u64,
1980        name: &OsStr,
1981        _mode: u32,
1982        _umask: u32,
1983        _flags: i32,
1984        reply: ReplyCreate,
1985    ) {
1986        if !self.has_session() {
1987            reply.error(libc::EROFS);
1988            return;
1989        }
1990
1991        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
1992            reply.error(libc::EROFS);
1993            return;
1994        };
1995
1996        let Some(name_s) = name.to_str() else {
1997            reply.error(libc::EINVAL);
1998            return;
1999        };
2000        let name_str = name_s.to_string();
2001
2002        let counter = self.alloc_overlay_ino();
2003        let created_id = Self::created_overlay_id(counter);
2004        let fuse_ino = rw_ino(counter + 9_000_000);
2005
2006        let mut session = self.session.borrow_mut();
2007        let s = session.as_mut().unwrap();
2008
2009        if s.write_overlay_file(&created_id, &[]).is_err() {
2010            reply.error(libc::EIO);
2011            return;
2012        }
2013
2014        s.overlay.created.insert(
2015            created_id,
2016            crate::session::OverlayEntry {
2017                parent_ino: fs_parent,
2018                name: name_str,
2019                size: 0,
2020            },
2021        );
2022
2023        if s.save().is_err() {
2024            reply.error(libc::EIO);
2025            return;
2026        }
2027
2028        let attr = Self::overlay_created_attr(fuse_ino, 0, false);
2029        reply.created(&TTL, &attr, 0, 0, 0);
2030    }
2031
2032    fn mkdir(
2033        &mut self,
2034        _req: &Request,
2035        parent: u64,
2036        name: &OsStr,
2037        _mode: u32,
2038        _umask: u32,
2039        reply: ReplyEntry,
2040    ) {
2041        if !self.has_session() {
2042            reply.error(libc::EROFS);
2043            return;
2044        }
2045
2046        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
2047            reply.error(libc::EROFS);
2048            return;
2049        };
2050
2051        let Some(name_s) = name.to_str() else {
2052            reply.error(libc::EINVAL);
2053            return;
2054        };
2055        let name_str = name_s.to_string();
2056
2057        let counter = self.alloc_overlay_ino();
2058        let created_id = Self::created_overlay_id(counter);
2059        let fuse_ino = rw_ino(counter + 9_000_000);
2060
2061        let mut session = self.session.borrow_mut();
2062        let s = session.as_mut().unwrap();
2063
2064        s.overlay.dirs.insert(
2065            created_id,
2066            crate::session::OverlayEntry {
2067                parent_ino: fs_parent,
2068                name: name_str,
2069                size: 0,
2070            },
2071        );
2072
2073        if s.save().is_err() {
2074            reply.error(libc::EIO);
2075            return;
2076        }
2077
2078        let attr = Self::overlay_created_attr(fuse_ino, 0, true);
2079        reply.entry(&TTL, &attr, 0);
2080    }
2081
2082    fn unlink(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEmpty) {
2083        if !self.has_session() {
2084            reply.error(libc::EROFS);
2085            return;
2086        }
2087
2088        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
2089            reply.error(libc::EROFS);
2090            return;
2091        };
2092
2093        let name_bytes = name.as_encoded_bytes();
2094
2095        // Check if it's a created overlay file first.
2096        if let Some((id, _counter, _is_dir)) = self.find_created_by_name(fs_parent, name_bytes) {
2097            let mut session = self.session.borrow_mut();
2098            let s = session.as_mut().unwrap();
2099            s.overlay.created.remove(&id);
2100            s.overlay.dirs.remove(&id);
2101            let _ = std::fs::remove_file(s.overlay_file_path(&id));
2102            if s.save().is_err() {
2103                reply.error(libc::EIO);
2104                return;
2105            }
2106            reply.ok();
2107            return;
2108        }
2109
2110        // Look up the fs inode and add whiteout.
2111        let mut fs = self.fs.borrow_mut();
2112        match fs.lookup(fs_parent, name_bytes) {
2113            Ok(Some(child_ino)) => {
2114                let mut session = self.session.borrow_mut();
2115                let s = session.as_mut().unwrap();
2116                if !s.overlay.deleted.contains(&child_ino) {
2117                    s.overlay.deleted.push(child_ino);
2118                }
2119                if s.save().is_err() {
2120                    reply.error(libc::EIO);
2121                    return;
2122                }
2123                reply.ok();
2124            }
2125            Ok(None) => reply.error(libc::ENOENT),
2126            Err(_) => reply.error(libc::EIO),
2127        }
2128    }
2129
2130    fn rmdir(&mut self, req: &Request, parent: u64, name: &OsStr, reply: ReplyEmpty) {
2131        self.unlink(req, parent, name, reply);
2132    }
2133
2134    #[allow(clippy::too_many_arguments)]
2135    fn setattr(
2136        &mut self,
2137        _req: &Request,
2138        ino: u64,
2139        _mode: Option<u32>,
2140        _uid: Option<u32>,
2141        _gid: Option<u32>,
2142        size: Option<u64>,
2143        _atime: Option<TimeOrNow>,
2144        _mtime: Option<TimeOrNow>,
2145        _ctime: Option<SystemTime>,
2146        _fh: Option<u64>,
2147        _crtime: Option<SystemTime>,
2148        _chgtime: Option<SystemTime>,
2149        _bkuptime: Option<SystemTime>,
2150        _flags: Option<u32>,
2151        reply: ReplyAttr,
2152    ) {
2153        match decode_fuse_ino(ino) {
2154            InodeNamespace::Rw(rw_id) => {
2155                if !self.has_session() {
2156                    reply.error(libc::EROFS);
2157                    return;
2158                }
2159
2160                // Handle truncate (size change).
2161                if let Some(new_size) = size {
2162                    // Created overlay file.
2163                    if rw_id >= 9_000_000 {
2164                        let counter = rw_id - 9_000_000;
2165                        let created_id = Self::created_overlay_id(counter);
2166                        let mut session = self.session.borrow_mut();
2167                        let s = session.as_mut().unwrap();
2168
2169                        let mut buf = s.read_overlay_file(&created_id).unwrap_or_default();
2170                        buf.resize(new_size as usize, 0);
2171                        if s.write_overlay_file(&created_id, &buf).is_err() {
2172                            reply.error(libc::EIO);
2173                            return;
2174                        }
2175
2176                        if let Some(entry) = s.overlay.created.get_mut(&created_id) {
2177                            entry.size = new_size;
2178                        }
2179                        if let Some(entry) = s.overlay.dirs.get_mut(&created_id) {
2180                            entry.size = new_size;
2181                        }
2182                        if s.save().is_err() {
2183                            reply.error(libc::EIO);
2184                            return;
2185                        }
2186
2187                        let attr = Self::overlay_created_attr(ino, new_size, false);
2188                        reply.attr(&TTL, &attr);
2189                        return;
2190                    }
2191
2192                    // Existing fs inode — COW then truncate.
2193                    let fs_ino = rw_id;
2194                    let overlay_id = Self::modified_overlay_id(fs_ino);
2195
2196                    let mut session = self.session.borrow_mut();
2197                    let s = session.as_mut().unwrap();
2198
2199                    if !s.overlay.modified.contains_key(&fs_ino) {
2200                        let mut fs = self.fs.borrow_mut();
2201                        let Ok(original) = fs.read_file(fs_ino) else {
2202                            reply.error(libc::EIO);
2203                            return;
2204                        };
2205                        if s.write_overlay_file(&overlay_id, &original).is_err() {
2206                            reply.error(libc::EIO);
2207                            return;
2208                        }
2209                        s.overlay.modified.insert(fs_ino, overlay_id.clone());
2210                    }
2211
2212                    let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
2213                    buf.resize(new_size as usize, 0);
2214                    if s.write_overlay_file(&overlay_id, &buf).is_err() {
2215                        reply.error(libc::EIO);
2216                        return;
2217                    }
2218                    if s.save().is_err() {
2219                        reply.error(libc::EIO);
2220                        return;
2221                    }
2222
2223                    // Return updated attrs.
2224                    let mut fs = self.fs.borrow_mut();
2225                    match fs.metadata(fs_ino) {
2226                        Ok(meta) => {
2227                            let mut attr = fs_to_attr(ino, &meta);
2228                            attr.size = new_size;
2229                            attr.blocks = new_size.div_ceil(512);
2230                            reply.attr(&TTL, &attr);
2231                        }
2232                        Err(_) => reply.error(libc::EIO),
2233                    }
2234                    return;
2235                }
2236
2237                // No size change — just return current attrs.
2238                if rw_id >= 9_000_000 {
2239                    let counter = rw_id - 9_000_000;
2240                    let created_id = Self::created_overlay_id(counter);
2241                    let session = self.session.borrow();
2242                    if let Some(s) = session.as_ref() {
2243                        if let Some(entry) = s.overlay.created.get(&created_id) {
2244                            let attr = Self::overlay_created_attr(ino, entry.size, false);
2245                            reply.attr(&TTL, &attr);
2246                            return;
2247                        }
2248                        if let Some(entry) = s.overlay.dirs.get(&created_id) {
2249                            let attr = Self::overlay_created_attr(ino, entry.size, true);
2250                            reply.attr(&TTL, &attr);
2251                            return;
2252                        }
2253                    }
2254                    reply.error(libc::ENOENT);
2255                } else {
2256                    let fs_ino = rw_id;
2257                    let mut fs = self.fs.borrow_mut();
2258                    match fs.metadata(fs_ino) {
2259                        Ok(meta) => {
2260                            let mut attr = fs_to_attr(ino, &meta);
2261                            let session = self.session.borrow();
2262                            if let Some(s) = session.as_ref() {
2263                                let overlay_id = Self::modified_overlay_id(fs_ino);
2264                                if s.overlay.modified.contains_key(&fs_ino) {
2265                                    if let Ok(data) = s.read_overlay_file(&overlay_id) {
2266                                        attr.size = data.len() as u64;
2267                                        attr.blocks = attr.size.div_ceil(512);
2268                                    }
2269                                }
2270                            }
2271                            reply.attr(&TTL, &attr);
2272                        }
2273                        Err(_) => reply.error(libc::EIO),
2274                    }
2275                }
2276            }
2277            // For non-rw inodes, setattr is not supported.
2278            _ => reply.error(libc::EROFS),
2279        }
2280    }
2281}
2282
2283#[cfg(test)]
2284mod tests {
2285    use super::*;
2286    use crate::{FsDeletedInode, FsDirEntry, FsError, FsRecoveryResult, FsResult, FsTimelineEvent};
2287    use fuser::FileType;
2288    use std::time::{Duration, UNIX_EPOCH};
2289
2290    // -----------------------------------------------------------------------
2291    // virtual_dir_attr
2292    // -----------------------------------------------------------------------
2293
2294    #[test]
2295    fn virtual_dir_attr_is_directory() {
2296        let attr = virtual_dir_attr(1);
2297        assert_eq!(attr.ino, 1);
2298        assert_eq!(attr.kind, FileType::Directory);
2299        assert_eq!(attr.perm, 0o555);
2300        assert_eq!(attr.nlink, 2);
2301        assert_eq!(attr.size, 0);
2302        assert_eq!(attr.blocks, 0);
2303        assert_eq!(attr.uid, 0);
2304        assert_eq!(attr.gid, 0);
2305        assert_eq!(attr.blksize, 4096);
2306        assert_eq!(attr.atime, UNIX_EPOCH);
2307        assert_eq!(attr.mtime, UNIX_EPOCH);
2308        assert_eq!(attr.ctime, UNIX_EPOCH);
2309        assert_eq!(attr.crtime, UNIX_EPOCH);
2310    }
2311
2312    #[test]
2313    fn virtual_dir_attr_preserves_ino() {
2314        for ino in [1, 42, FUSE_ROOT_INO, FUSE_ORPHANS_INO, 999_999] {
2315            assert_eq!(virtual_dir_attr(ino).ino, ino);
2316        }
2317    }
2318
2319    // -----------------------------------------------------------------------
2320    // virtual_file_attr
2321    // -----------------------------------------------------------------------
2322
2323    #[test]
2324    fn virtual_file_attr_regular() {
2325        let attr = virtual_file_attr(100, 4096);
2326        assert_eq!(attr.ino, 100);
2327        assert_eq!(attr.kind, FileType::RegularFile);
2328        assert_eq!(attr.perm, 0o444);
2329        assert_eq!(attr.nlink, 1);
2330        assert_eq!(attr.size, 4096);
2331        assert_eq!(attr.blocks, 8); // 4096 / 512
2332    }
2333
2334    #[test]
2335    fn virtual_file_attr_zero_size() {
2336        let attr = virtual_file_attr(1, 0);
2337        assert_eq!(attr.size, 0);
2338        assert_eq!(attr.blocks, 0);
2339    }
2340
2341    #[test]
2342    fn virtual_file_attr_non_512_aligned() {
2343        // 1000 bytes -> ceil(1000/512) = 2 blocks
2344        let attr = virtual_file_attr(1, 1000);
2345        assert_eq!(attr.blocks, 2);
2346    }
2347
2348    // -----------------------------------------------------------------------
2349    // ts_to_systime
2350    // -----------------------------------------------------------------------
2351
2352    #[test]
2353    fn timestamp_conversion_positive() {
2354        let ts = FsTimestamp {
2355            seconds: 1_700_000_000,
2356            nanoseconds: 500_000_000,
2357        };
2358        let st = ts_to_systime(&ts);
2359        let dur = st.duration_since(UNIX_EPOCH).unwrap();
2360        assert_eq!(dur.as_secs(), 1_700_000_000);
2361        assert_eq!(dur.subsec_nanos(), 500_000_000);
2362    }
2363
2364    #[test]
2365    fn timestamp_zero() {
2366        let ts = FsTimestamp {
2367            seconds: 0,
2368            nanoseconds: 0,
2369        };
2370        let st = ts_to_systime(&ts);
2371        assert_eq!(st, UNIX_EPOCH);
2372    }
2373
2374    #[test]
2375    fn timestamp_negative_clamps_to_epoch() {
2376        let ts = FsTimestamp {
2377            seconds: -1,
2378            nanoseconds: 0,
2379        };
2380        let st = ts_to_systime(&ts);
2381        assert_eq!(st, UNIX_EPOCH);
2382    }
2383
2384    #[test]
2385    fn timestamp_negative_large_clamps_to_epoch() {
2386        let ts = FsTimestamp {
2387            seconds: -1_000_000,
2388            nanoseconds: 999_999_999,
2389        };
2390        let st = ts_to_systime(&ts);
2391        assert_eq!(st, UNIX_EPOCH);
2392    }
2393
2394    #[test]
2395    fn timestamp_epoch_plus_one_second() {
2396        let ts = FsTimestamp {
2397            seconds: 1,
2398            nanoseconds: 0,
2399        };
2400        let st = ts_to_systime(&ts);
2401        assert_eq!(st, UNIX_EPOCH + Duration::from_secs(1));
2402    }
2403
2404    // -----------------------------------------------------------------------
2405    // fs_file_type_to_fuse
2406    // -----------------------------------------------------------------------
2407
2408    #[test]
2409    fn file_type_mapping_regular() {
2410        assert_eq!(
2411            fs_file_type_to_fuse(FsFileType::RegularFile),
2412            FileType::RegularFile
2413        );
2414    }
2415
2416    #[test]
2417    fn file_type_mapping_directory() {
2418        assert_eq!(
2419            fs_file_type_to_fuse(FsFileType::Directory),
2420            FileType::Directory
2421        );
2422    }
2423
2424    #[test]
2425    fn file_type_mapping_symlink() {
2426        assert_eq!(fs_file_type_to_fuse(FsFileType::Symlink), FileType::Symlink);
2427    }
2428
2429    #[test]
2430    fn file_type_mapping_chardev() {
2431        assert_eq!(
2432            fs_file_type_to_fuse(FsFileType::CharDevice),
2433            FileType::CharDevice
2434        );
2435    }
2436
2437    #[test]
2438    fn file_type_mapping_blockdev() {
2439        assert_eq!(
2440            fs_file_type_to_fuse(FsFileType::BlockDevice),
2441            FileType::BlockDevice
2442        );
2443    }
2444
2445    #[test]
2446    fn file_type_mapping_fifo() {
2447        assert_eq!(fs_file_type_to_fuse(FsFileType::Fifo), FileType::NamedPipe);
2448    }
2449
2450    #[test]
2451    fn file_type_mapping_socket() {
2452        assert_eq!(fs_file_type_to_fuse(FsFileType::Socket), FileType::Socket);
2453    }
2454
2455    #[test]
2456    fn file_type_mapping_unknown() {
2457        assert_eq!(
2458            fs_file_type_to_fuse(FsFileType::Unknown),
2459            FileType::RegularFile
2460        );
2461    }
2462
2463    // -----------------------------------------------------------------------
2464    // fs_to_attr
2465    // -----------------------------------------------------------------------
2466
2467    #[test]
2468    fn fs_to_attr_regular_file() {
2469        let meta = FsMetadata {
2470            ino: 42,
2471            file_type: FsFileType::RegularFile,
2472            mode: 0o100_644,
2473            uid: 1000,
2474            gid: 1000,
2475            size: 100,
2476            links_count: 1,
2477            atime: FsTimestamp {
2478                seconds: 1_700_000_000,
2479                nanoseconds: 0,
2480            },
2481            mtime: FsTimestamp {
2482                seconds: 1_700_000_000,
2483                nanoseconds: 0,
2484            },
2485            ctime: FsTimestamp {
2486                seconds: 1_700_000_000,
2487                nanoseconds: 0,
2488            },
2489            crtime: FsTimestamp {
2490                seconds: 1_700_000_000,
2491                nanoseconds: 0,
2492            },
2493            allocated: true,
2494        };
2495        let attr = fs_to_attr(1012, &meta);
2496        assert_eq!(attr.ino, 1012);
2497        assert_eq!(attr.size, 100);
2498        assert_eq!(attr.kind, FileType::RegularFile);
2499        assert_eq!(attr.nlink, 1);
2500        assert_eq!(attr.perm, 0o644);
2501        assert_eq!(attr.uid, 1000);
2502        assert_eq!(attr.gid, 1000);
2503    }
2504
2505    #[test]
2506    fn fs_to_attr_directory() {
2507        let meta = FsMetadata {
2508            ino: 2,
2509            file_type: FsFileType::Directory,
2510            mode: 0o40755,
2511            uid: 0,
2512            gid: 0,
2513            size: 4096,
2514            links_count: 3,
2515            atime: FsTimestamp::default(),
2516            mtime: FsTimestamp::default(),
2517            ctime: FsTimestamp::default(),
2518            crtime: FsTimestamp::default(),
2519            allocated: true,
2520        };
2521        let attr = fs_to_attr(2000, &meta);
2522        assert_eq!(attr.kind, FileType::Directory);
2523        assert_eq!(attr.nlink, 3);
2524        assert_eq!(attr.perm, 0o755);
2525    }
2526
2527    #[test]
2528    fn fs_to_attr_symlink() {
2529        let meta = FsMetadata {
2530            ino: 10,
2531            file_type: FsFileType::Symlink,
2532            mode: 0o120_777,
2533            uid: 0,
2534            gid: 0,
2535            size: 11,
2536            links_count: 1,
2537            atime: FsTimestamp::default(),
2538            mtime: FsTimestamp::default(),
2539            ctime: FsTimestamp::default(),
2540            crtime: FsTimestamp::default(),
2541            allocated: true,
2542        };
2543        let attr = fs_to_attr(3000, &meta);
2544        assert_eq!(attr.kind, FileType::Symlink);
2545        assert_eq!(attr.perm, 0o777);
2546    }
2547
2548    #[test]
2549    fn fs_to_attr_blocks_calculation() {
2550        let meta = FsMetadata {
2551            ino: 42,
2552            file_type: FsFileType::RegularFile,
2553            mode: 0o100_644,
2554            uid: 0,
2555            gid: 0,
2556            size: 1000,
2557            links_count: 1,
2558            atime: FsTimestamp::default(),
2559            mtime: FsTimestamp::default(),
2560            ctime: FsTimestamp::default(),
2561            crtime: FsTimestamp::default(),
2562            allocated: true,
2563        };
2564        let attr = fs_to_attr(42, &meta);
2565        assert_eq!(attr.size, 1000);
2566        assert_eq!(attr.blocks, 2);
2567    }
2568
2569    #[test]
2570    fn fs_to_attr_blksize_always_4096() {
2571        let meta = FsMetadata {
2572            ino: 1,
2573            file_type: FsFileType::RegularFile,
2574            mode: 0o100_644,
2575            uid: 0,
2576            gid: 0,
2577            size: 0,
2578            links_count: 1,
2579            atime: FsTimestamp::default(),
2580            mtime: FsTimestamp::default(),
2581            ctime: FsTimestamp::default(),
2582            crtime: FsTimestamp::default(),
2583            allocated: true,
2584        };
2585        let attr = fs_to_attr(1, &meta);
2586        assert_eq!(attr.blksize, 4096);
2587    }
2588
2589    // -----------------------------------------------------------------------
2590    // ForensicFuseFs::overlay_created_attr
2591    // -----------------------------------------------------------------------
2592
2593    #[test]
2594    fn overlay_created_attr_regular_file() {
2595        let attr = ForensicFuseFs::overlay_created_attr(999, 512, false);
2596        assert_eq!(attr.ino, 999);
2597        assert_eq!(attr.size, 512);
2598        assert_eq!(attr.kind, FileType::RegularFile);
2599        assert_eq!(attr.perm, 0o644);
2600        assert_eq!(attr.nlink, 1);
2601        assert_eq!(attr.blocks, 1);
2602    }
2603
2604    #[test]
2605    fn overlay_created_attr_directory() {
2606        let attr = ForensicFuseFs::overlay_created_attr(888, 0, true);
2607        assert_eq!(attr.kind, FileType::Directory);
2608        assert_eq!(attr.perm, 0o755);
2609    }
2610
2611    // -----------------------------------------------------------------------
2612    // ForensicFuseFs helper methods (static/associated)
2613    // -----------------------------------------------------------------------
2614
2615    #[test]
2616    fn modified_overlay_id_format() {
2617        assert_eq!(ForensicFuseFs::modified_overlay_id(42), "ino_42");
2618        assert_eq!(ForensicFuseFs::modified_overlay_id(0), "ino_0");
2619        assert_eq!(
2620            ForensicFuseFs::modified_overlay_id(9_999_999),
2621            "ino_9999999"
2622        );
2623    }
2624
2625    #[test]
2626    fn created_overlay_id_format() {
2627        assert_eq!(ForensicFuseFs::created_overlay_id(1), "new_1");
2628        assert_eq!(ForensicFuseFs::created_overlay_id(0), "new_0");
2629    }
2630
2631    // -----------------------------------------------------------------------
2632    // root_children — MountLayout decision (Humble Object)
2633    // -----------------------------------------------------------------------
2634
2635    fn root_child_names(layout: crate::MountLayout) -> Vec<String> {
2636        let mut fs = MockForensicFs;
2637        let root = fs.root_ino();
2638        root_children(layout, &mut fs, root, false)
2639            .unwrap()
2640            .iter()
2641            .map(|(_, n, _)| String::from_utf8_lossy(n).to_string())
2642            .collect()
2643    }
2644
2645    #[test]
2646    fn root_children_raw_lists_fs_tree_without_overlay() {
2647        let names = root_child_names(crate::MountLayout::Raw);
2648        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
2649        assert!(names.contains(&"subdir".to_string()), "got {names:?}");
2650        assert!(
2651            !names
2652                .iter()
2653                .any(|n| n == "rw" || n == "deleted" || n == "ro"),
2654            "Raw root must have no overlay dirs: {names:?}"
2655        );
2656    }
2657
2658    #[test]
2659    fn root_children_diskoverlay_lists_virtual_dirs() {
2660        let names = root_child_names(crate::MountLayout::DiskOverlay);
2661        for d in ["ro", "rw", "journal", "metadata", "unallocated", "session"] {
2662            assert!(names.contains(&d.to_string()), "missing {d}: {names:?}");
2663        }
2664        // The flat deleted/ directory is gone in v2 (in-place rendering).
2665        assert!(!names.contains(&"deleted".to_string()), "got {names:?}");
2666    }
2667
2668    #[test]
2669    fn root_children_diskoverlay_appends_orphans_when_present() {
2670        let mut fs = MockForensicFs;
2671        let root = fs.root_ino();
2672        let with: Vec<String> = root_children(crate::MountLayout::DiskOverlay, &mut fs, root, true)
2673            .unwrap()
2674            .iter()
2675            .map(|(_, n, _)| String::from_utf8_lossy(n).to_string())
2676            .collect();
2677        assert!(with.contains(&"$Orphans".to_string()), "got {with:?}");
2678    }
2679
2680    // -----------------------------------------------------------------------
2681    // VIRTUAL_DIRS constant
2682    // -----------------------------------------------------------------------
2683
2684    #[test]
2685    fn virtual_dirs_has_expected_entries() {
2686        // v2: the flat deleted/ dir is gone; six fixed virtual dirs remain.
2687        assert_eq!(VIRTUAL_DIRS.len(), 6);
2688        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "ro"));
2689        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "rw"));
2690        assert!(!VIRTUAL_DIRS.iter().any(|(_, name)| *name == "deleted"));
2691        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "journal"));
2692        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "metadata"));
2693        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "unallocated"));
2694        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "session"));
2695    }
2696
2697    #[test]
2698    fn virtual_dirs_ino_matches_constants() {
2699        for &(ino, name) in VIRTUAL_DIRS {
2700            match name {
2701                "ro" => assert_eq!(ino, FUSE_RO_INO),
2702                "rw" => assert_eq!(ino, FUSE_RW_INO),
2703                "journal" => assert_eq!(ino, FUSE_JOURNAL_INO),
2704                "metadata" => assert_eq!(ino, FUSE_METADATA_INO),
2705                "unallocated" => assert_eq!(ino, FUSE_UNALLOCATED_INO),
2706                "session" => assert_eq!(ino, FUSE_SESSION_INO),
2707                _ => panic!("unexpected virtual dir: {name}"),
2708            }
2709        }
2710    }
2711
2712    // -----------------------------------------------------------------------
2713    // MockForensicFs + FUSE dispatch tests
2714    // -----------------------------------------------------------------------
2715
2716    struct MockForensicFs;
2717
2718    impl crate::ForensicFs for MockForensicFs {
2719        fn root_ino(&self) -> u64 {
2720            2
2721        }
2722
2723        fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
2724            match ino {
2725                2 => Ok(vec![
2726                    FsDirEntry {
2727                        inode: 2,
2728                        name: b".".to_vec(),
2729                        file_type: FsFileType::Directory,
2730                    },
2731                    FsDirEntry {
2732                        inode: 2,
2733                        name: b"..".to_vec(),
2734                        file_type: FsFileType::Directory,
2735                    },
2736                    FsDirEntry {
2737                        inode: 10,
2738                        name: b"hello.txt".to_vec(),
2739                        file_type: FsFileType::RegularFile,
2740                    },
2741                    FsDirEntry {
2742                        inode: 11,
2743                        name: b"subdir".to_vec(),
2744                        file_type: FsFileType::Directory,
2745                    },
2746                ]),
2747                11 => Ok(vec![
2748                    FsDirEntry {
2749                        inode: 11,
2750                        name: b".".to_vec(),
2751                        file_type: FsFileType::Directory,
2752                    },
2753                    FsDirEntry {
2754                        inode: 2,
2755                        name: b"..".to_vec(),
2756                        file_type: FsFileType::Directory,
2757                    },
2758                    FsDirEntry {
2759                        inode: 12,
2760                        name: b"nested.txt".to_vec(),
2761                        file_type: FsFileType::RegularFile,
2762                    },
2763                ]),
2764                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2765            }
2766        }
2767
2768        fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
2769            let entries = self.read_dir(parent_ino)?;
2770            Ok(entries.iter().find(|e| e.name == name).map(|e| e.inode))
2771        }
2772
2773        fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
2774            let (file_type, size) = match ino {
2775                2 | 11 => (FsFileType::Directory, 4096),
2776                10 => (FsFileType::RegularFile, 12),
2777                12 => (FsFileType::RegularFile, 11),
2778                _ => return Err(FsError::NotFound(format!("inode {ino}"))),
2779            };
2780            Ok(FsMetadata {
2781                ino,
2782                file_type,
2783                mode: if file_type == FsFileType::Directory {
2784                    0o40755
2785                } else {
2786                    0o100_644
2787                },
2788                uid: 1000,
2789                gid: 1000,
2790                size: size as u64,
2791                links_count: if file_type == FsFileType::Directory {
2792                    2
2793                } else {
2794                    1
2795                },
2796                atime: FsTimestamp {
2797                    seconds: 1_700_000_000,
2798                    nanoseconds: 0,
2799                },
2800                mtime: FsTimestamp {
2801                    seconds: 1_700_000_000,
2802                    nanoseconds: 0,
2803                },
2804                ctime: FsTimestamp {
2805                    seconds: 1_700_000_000,
2806                    nanoseconds: 0,
2807                },
2808                crtime: FsTimestamp {
2809                    seconds: 1_699_000_000,
2810                    nanoseconds: 0,
2811                },
2812                allocated: true,
2813            })
2814        }
2815
2816        fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
2817            match ino {
2818                10 => Ok(b"Hello, mock!".to_vec()),
2819                12 => Ok(b"Nested file".to_vec()),
2820                // Deleted nodes whose content is recoverable.
2821                100..=103 => Ok(vec![0xAB; 100]),
2822                // 104 (gone.txt) deliberately unreadable — falls through to Err.
2823                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2824            }
2825        }
2826
2827        fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
2828            let data = self.read_file(ino)?;
2829            let start = (offset as usize).min(data.len());
2830            let end = (start + len as usize).min(data.len());
2831            Ok(data[start..end].to_vec())
2832        }
2833
2834        fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
2835            Err(FsError::NotFound("no symlinks in mock".to_string()))
2836        }
2837
2838        fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
2839            Ok(vec![FsDeletedInode {
2840                ino: 99,
2841                file_type: FsFileType::RegularFile,
2842                size: 100,
2843                dtime: 1_700_001_000,
2844                recoverability: 0.75,
2845            }])
2846        }
2847
2848        fn deleted_nodes(&mut self) -> FsResult<Vec<crate::FsDeletedNode>> {
2849            let mk = |ino: u64,
2850                      name: &[u8],
2851                      parent: Option<u64>,
2852                      mtime: i64,
2853                      record_id: u64,
2854                      allocation: crate::FsAllocation| {
2855                crate::FsDeletedNode {
2856                    ino,
2857                    name: name.to_vec(),
2858                    parent_ino: parent,
2859                    size: 100,
2860                    file_type: FsFileType::RegularFile,
2861                    allocation,
2862                    record_id,
2863                    atime: FsTimestamp::default(),
2864                    mtime: FsTimestamp {
2865                        seconds: mtime,
2866                        nanoseconds: 0,
2867                    },
2868                    ctime: FsTimestamp::default(),
2869                    crtime: FsTimestamp::default(),
2870                }
2871            };
2872            Ok(vec![
2873                // Two same-name deletes under the live root (ino 2): newest wins
2874                // the in-place slot, the older is a same-name orphan.
2875                mk(
2876                    100,
2877                    b"report.txt",
2878                    Some(2),
2879                    200,
2880                    100,
2881                    crate::FsAllocation::Deleted,
2882                ),
2883                mk(
2884                    101,
2885                    b"report.txt",
2886                    Some(2),
2887                    100,
2888                    101,
2889                    crate::FsAllocation::Deleted,
2890                ),
2891                // Collides with the live hello.txt (ino 10) -> $Orphans.
2892                mk(
2893                    102,
2894                    b"hello.txt",
2895                    Some(2),
2896                    300,
2897                    102,
2898                    crate::FsAllocation::Deleted,
2899                ),
2900                // Nameless true orphan (no parent) -> $Orphans.
2901                mk(103, b"", None, 150, 103, crate::FsAllocation::Orphan),
2902                // In-place candidate whose content is unreadable (ino 104 errors
2903                // in read_file) -> honest unreadable marker, never a 0-byte fake.
2904                mk(
2905                    104,
2906                    b"gone.txt",
2907                    Some(2),
2908                    250,
2909                    104,
2910                    crate::FsAllocation::Deleted,
2911                ),
2912            ])
2913        }
2914
2915        fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
2916            match ino {
2917                99 => Ok(FsRecoveryResult {
2918                    ino: 99,
2919                    data: vec![0xDE; 100],
2920                    expected_size: 100,
2921                    recovered_bytes: 100,
2922                    recovery_percentage: 1.0,
2923                }),
2924                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2925            }
2926        }
2927
2928        fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
2929            Ok(vec![FsTimelineEvent {
2930                timestamp: FsTimestamp {
2931                    seconds: 1_700_000_000,
2932                    nanoseconds: 0,
2933                },
2934                event_type: FsEventType::Created,
2935                inode: 10,
2936                size: 12,
2937                uid: 1000,
2938                gid: 1000,
2939            }])
2940        }
2941
2942        fn fs_info(&self) -> FsResult<serde_json::Value> {
2943            Ok(serde_json::json!({ "filesystem": "mock", "block_size": 4096 }))
2944        }
2945
2946        fn block_size(&self) -> u64 {
2947            4096
2948        }
2949    }
2950
2951    fn make_mock_fuse() -> ForensicFuseFs {
2952        ForensicFuseFs::new(
2953            Box::new(MockForensicFs),
2954            None,
2955            crate::MountLayout::DiskOverlay,
2956            crate::DeletedMode::Latest,
2957        )
2958    }
2959
2960    #[test]
2961    fn mock_ensure_deleted_cache() {
2962        let fuse = make_mock_fuse();
2963        assert!(fuse.deleted_cache.borrow().is_none());
2964        fuse.ensure_deleted_cache();
2965        let cache = fuse.deleted_cache.borrow();
2966        let entries = cache.as_ref().expect("cache should be populated");
2967        // Five recovered nodes from the mock, real names — never fabricated.
2968        assert_eq!(entries.len(), 5);
2969        assert!(entries
2970            .iter()
2971            .any(|e| e.fs_ino == 100 && e.name == "report.txt"));
2972    }
2973
2974    #[test]
2975    fn mock_ensure_metadata_cache() {
2976        let fuse = make_mock_fuse();
2977        assert!(fuse.metadata_cache.borrow().is_none());
2978        fuse.ensure_metadata_cache();
2979        let cache = fuse.metadata_cache.borrow();
2980        let mc = cache.as_ref().expect("cache should be populated");
2981        let sb_str = String::from_utf8_lossy(&mc.superblock_json);
2982        assert!(
2983            sb_str.contains("mock"),
2984            "superblock_json should contain 'mock': {sb_str}"
2985        );
2986        assert!(
2987            !mc.timeline_jsonl.is_empty(),
2988            "timeline_jsonl should not be empty"
2989        );
2990    }
2991
2992    #[test]
2993    fn mock_root_ino_stored() {
2994        let fuse = make_mock_fuse();
2995        assert_eq!(fuse.root_ino, 2);
2996    }
2997
2998    #[test]
2999    fn mock_has_session_false() {
3000        let fuse = make_mock_fuse();
3001        assert!(!fuse.has_session());
3002    }
3003
3004    #[test]
3005    fn mock_read_file_through_fs() {
3006        let fuse = make_mock_fuse();
3007        let mut fs = fuse.fs.borrow_mut();
3008        let data = fs.read_file(10).expect("read_file(10) should succeed");
3009        assert_eq!(data, b"Hello, mock!");
3010    }
3011
3012    #[test]
3013    fn mock_read_file_range_through_fs() {
3014        let fuse = make_mock_fuse();
3015        let mut fs = fuse.fs.borrow_mut();
3016        let data = fs
3017            .read_file_range(10, 0, 5)
3018            .expect("read_file_range should succeed");
3019        assert_eq!(data, b"Hello");
3020    }
3021
3022    #[test]
3023    fn mock_lookup_through_fs() {
3024        let fuse = make_mock_fuse();
3025        let mut fs = fuse.fs.borrow_mut();
3026        let result = fs.lookup(2, b"hello.txt").expect("lookup should succeed");
3027        assert_eq!(result, Some(10));
3028    }
3029
3030    #[test]
3031    fn mock_metadata_through_fs() {
3032        let fuse = make_mock_fuse();
3033        let mut fs = fuse.fs.borrow_mut();
3034        let meta = fs.metadata(10).expect("metadata(10) should succeed");
3035        assert_eq!(meta.file_type, FsFileType::RegularFile);
3036        assert_eq!(meta.size, 12);
3037        assert_eq!(meta.ino, 10);
3038    }
3039
3040    #[test]
3041    fn mock_fs_to_attr() {
3042        let fuse = make_mock_fuse();
3043        let meta = {
3044            let mut fs = fuse.fs.borrow_mut();
3045            fs.metadata(10).expect("metadata(10) should succeed")
3046        };
3047        let attr = fs_to_attr(ro_ino(10), &meta);
3048        assert_eq!(attr.ino, ro_ino(10));
3049        assert_eq!(attr.kind, FileType::RegularFile);
3050        assert_eq!(attr.size, 12);
3051        assert_eq!(attr.perm, 0o644);
3052        assert_eq!(attr.uid, 1000);
3053        assert_eq!(attr.gid, 1000);
3054        assert_eq!(attr.nlink, 1);
3055        assert_eq!(attr.blksize, 4096);
3056        let expected_atime = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
3057        assert_eq!(attr.atime, expected_atime);
3058        let expected_crtime = UNIX_EPOCH + Duration::from_secs(1_699_000_000);
3059        assert_eq!(attr.crtime, expected_crtime);
3060    }
3061
3062    #[test]
3063    fn mock_timeline_through_fs() {
3064        let fuse = make_mock_fuse();
3065        let mut fs = fuse.fs.borrow_mut();
3066        let events = fs.timeline().expect("timeline should succeed");
3067        assert_eq!(events.len(), 1);
3068        assert_eq!(events[0].event_type, FsEventType::Created);
3069        assert_eq!(events[0].inode, 10);
3070        assert_eq!(events[0].size, 12);
3071    }
3072
3073    #[test]
3074    fn mock_ensure_journal_cache_empty() {
3075        let fuse = make_mock_fuse();
3076        assert!(fuse.journal_cache.borrow().is_none());
3077        fuse.ensure_journal_cache();
3078        let cache = fuse.journal_cache.borrow();
3079        let entries = cache.as_ref().expect("cache should be populated");
3080        assert!(
3081            entries.is_empty(),
3082            "mock has no journal_transactions override, should be empty"
3083        );
3084    }
3085
3086    // -----------------------------------------------------------------------
3087    // Deleted-node placement (Task 2): real names, in-place vs $Orphans, gating
3088    // -----------------------------------------------------------------------
3089
3090    fn make_mock_fuse_mode(mode: crate::DeletedMode) -> ForensicFuseFs {
3091        ForensicFuseFs::new(
3092            Box::new(MockForensicFs),
3093            None,
3094            crate::MountLayout::DiskOverlay,
3095            mode,
3096        )
3097    }
3098
3099    #[test]
3100    fn filename_safe_utc_has_no_colons() {
3101        // 1_700_000_000 == 2023-11-14T22:13:20Z -> colons become hyphens.
3102        assert_eq!(filename_safe_utc(1_700_000_000), "2023-11-14T22-13-20Z");
3103    }
3104
3105    #[test]
3106    fn deleted_cache_latest_places_newest_in_place() {
3107        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3108        fuse.ensure_deleted_cache();
3109        let cache = fuse.deleted_cache.borrow();
3110        let entries = cache.as_ref().expect("cache populated");
3111        let by = |ino: u64| {
3112            entries
3113                .iter()
3114                .find(|e| e.fs_ino == ino)
3115                .unwrap_or_else(|| panic!("no cache entry for ino {ino}"))
3116        };
3117        // Newest report.txt renders in-place under its real name.
3118        let a = by(100);
3119        assert_eq!(a.name, "report.txt");
3120        assert!(!a.orphan);
3121        // Older same-name delete -> $Orphans, disambiguated `<name>@<ts>Z~<id>`.
3122        let b = by(101);
3123        assert!(b.orphan);
3124        assert!(b.name.starts_with("report.txt@"), "got {}", b.name);
3125        assert!(b.name.ends_with("~101"), "got {}", b.name);
3126        // Live-name collision -> $Orphans.
3127        assert!(by(102).orphan);
3128        // Nameless true orphan -> $Orphans.
3129        assert!(by(103).orphan);
3130        // In-place but unreadable content -> honest marker, not a 0-byte fake.
3131        let g = by(104);
3132        assert_eq!(g.name, "gone.txt");
3133        assert!(!g.orphan);
3134        assert!(!g.readable);
3135    }
3136
3137    #[test]
3138    fn deleted_cache_off_is_empty() {
3139        let fuse = make_mock_fuse_mode(crate::DeletedMode::Off);
3140        fuse.ensure_deleted_cache();
3141        assert!(fuse
3142            .deleted_cache
3143            .borrow()
3144            .as_ref()
3145            .expect("cache populated")
3146            .is_empty());
3147    }
3148
3149    #[test]
3150    fn deleted_cache_all_routes_everything_to_orphans() {
3151        let fuse = make_mock_fuse_mode(crate::DeletedMode::All);
3152        fuse.ensure_deleted_cache();
3153        let cache = fuse.deleted_cache.borrow();
3154        let entries = cache.as_ref().expect("cache populated");
3155        assert_eq!(entries.len(), 5);
3156        assert!(
3157            entries.iter().all(|e| e.orphan),
3158            "All -> every instance orphan"
3159        );
3160        let a = entries.iter().find(|e| e.fs_ino == 100).unwrap();
3161        assert!(a.name.starts_with("report.txt@"), "got {}", a.name);
3162    }
3163
3164    #[test]
3165    fn deleted_cache_never_fabricates_unknown_names() {
3166        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3167        fuse.ensure_deleted_cache();
3168        let cache = fuse.deleted_cache.borrow();
3169        for e in cache.as_ref().expect("cache populated") {
3170            assert!(!e.name.ends_with("_unknown"), "fabricated: {}", e.name);
3171        }
3172    }
3173
3174    #[test]
3175    fn timeline_jsonl_carries_every_deleted_instance() {
3176        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3177        fuse.ensure_metadata_cache();
3178        let cache = fuse.metadata_cache.borrow();
3179        let mc = cache.as_ref().expect("metadata cache populated");
3180        let text = String::from_utf8_lossy(&mc.timeline_jsonl);
3181        // A deleted-instance row is any JSONL line carrying a `placement` field.
3182        let rows: Vec<serde_json::Value> = text
3183            .lines()
3184            .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
3185            .filter(|v| v.get("placement").is_some())
3186            .collect();
3187        // One row per deleted instance (all 5 mock nodes).
3188        assert_eq!(rows.len(), 5, "one row per deleted instance");
3189        // Every version of a same-named deleted file (grep by name).
3190        let report: Vec<_> = rows.iter().filter(|r| r["name"] == "report.txt").collect();
3191        assert_eq!(report.len(), 2, "both report.txt instances present");
3192        assert!(report.iter().any(|r| r["placement"] == "in-place"));
3193        assert!(report.iter().any(|r| r["placement"] == "orphan"));
3194        // Required fields present on a row.
3195        let r = &rows[0];
3196        for f in ["path", "name", "record_id", "allocation", "status", "macb"] {
3197            assert!(r.get(f).is_some(), "row missing {f}: {r}");
3198        }
3199        assert!(r["macb"].get("modified").is_some());
3200        // Unreadable content surfaced honestly in the status.
3201        assert!(rows
3202            .iter()
3203            .any(|r| r["name"] == "gone.txt" && r["status"] == "unreadable"));
3204    }
3205
3206    // -----------------------------------------------------------------------
3207    // ADR 0008 v2 (a): in-place recovered-deleted entries render in the main
3208    // navigable tree at their recovered parent, under their real name.
3209    // -----------------------------------------------------------------------
3210
3211    #[test]
3212    fn in_place_children_injected_under_parent() {
3213        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3214        fuse.ensure_deleted_cache();
3215        let cache = fuse.deleted_cache.borrow();
3216        let entries = cache.as_ref().expect("cache populated");
3217        let kids = deleted_in_place_children(entries, 2);
3218        // report.txt (ino 100, newest) and gone.txt (ino 104) are the in-place
3219        // deletes under the live root (parent ino 2); orphans are excluded.
3220        let names: Vec<&str> = kids.iter().map(|(_, n)| n.as_str()).collect();
3221        assert!(names.contains(&"report.txt"), "got {names:?}");
3222        assert!(names.contains(&"gone.txt"), "got {names:?}");
3223        assert_eq!(
3224            kids.len(),
3225            2,
3226            "only the two in-place deletes under parent 2"
3227        );
3228        // Real recovered name — no `(deleted)` decoration.
3229        assert!(
3230            !names.iter().any(|n| n.contains("(deleted)")),
3231            "got {names:?}"
3232        );
3233        // Child inode is the deleted-namespace encoding, so getattr/read resolve it.
3234        assert!(kids.iter().any(|(ino, _)| *ino == deleted_ino(100)));
3235        // A directory with no recovered deleted children gets nothing injected.
3236        assert!(deleted_in_place_children(entries, 11).is_empty());
3237        // Orphans never inject in-place (ino 101/102/103 are routed to $Orphans).
3238        assert!(!kids.iter().any(|(ino, _)| *ino == deleted_ino(101)));
3239    }
3240
3241    // -----------------------------------------------------------------------
3242    // ADR 0008 v2 (b): `$Orphans/` is a top-level synthetic directory (not a
3243    // `deleted/` subtree), shown only when unplaceable entries exist.
3244    // -----------------------------------------------------------------------
3245
3246    #[test]
3247    fn orphans_dir_is_top_level_only_when_present() {
3248        use crate::inode_map::FUSE_ORPHANS_INO;
3249        // With orphans present, `$Orphans` joins the root listing; the flat
3250        // `deleted/` directory is gone.
3251        let with = root_dir_listing(true);
3252        assert!(
3253            with.iter()
3254                .any(|&(ino, n)| ino == FUSE_ORPHANS_INO && n == "$Orphans"),
3255            "root should list $Orphans when orphans exist: {with:?}"
3256        );
3257        assert!(
3258            !with.iter().any(|&(_, n)| n == "deleted"),
3259            "the flat deleted/ dir is removed in v2: {with:?}"
3260        );
3261        // Stable virtual dirs stay.
3262        for want in ["ro", "rw", "metadata", "session"] {
3263            assert!(
3264                with.iter().any(|&(_, n)| n == want),
3265                "missing {want}: {with:?}"
3266            );
3267        }
3268        // No orphans -> no $Orphans at the root.
3269        let without = root_dir_listing(false);
3270        assert!(
3271            !without.iter().any(|&(_, n)| n == "$Orphans"),
3272            "no $Orphans without orphan entries: {without:?}"
3273        );
3274    }
3275
3276    #[test]
3277    fn cache_has_orphans_reflects_placement() {
3278        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3279        fuse.ensure_deleted_cache();
3280        let cache = fuse.deleted_cache.borrow();
3281        let entries = cache.as_ref().expect("cache populated");
3282        // Mock routes 101/102/103 to $Orphans.
3283        assert!(cache_has_orphans(entries));
3284        assert!(!cache_has_orphans(&[]));
3285    }
3286
3287    // -----------------------------------------------------------------------
3288    // ADR 0008 v2 (c): the deleted status + recovered MACB times ride an
3289    // out-of-band xattr channel (user.4n6.*), never a name/mode decoration.
3290    // -----------------------------------------------------------------------
3291
3292    #[test]
3293    fn deleted_xattr_names_are_the_marking_schema() {
3294        let names = deleted_xattr_names();
3295        for want in [
3296            "user.4n6.status",
3297            "user.4n6.macb.modified",
3298            "user.4n6.macb.accessed",
3299            "user.4n6.macb.changed",
3300            "user.4n6.macb.born",
3301        ] {
3302            assert!(names.contains(&want), "missing {want}: {names:?}");
3303        }
3304        assert_eq!(names.len(), 5);
3305    }
3306
3307    #[test]
3308    fn deleted_xattr_value_status_and_macb() {
3309        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3310        fuse.ensure_deleted_cache();
3311        let cache = fuse.deleted_cache.borrow();
3312        let entries = cache.as_ref().expect("cache populated");
3313        let by = |ino: u64| entries.iter().find(|e| e.fs_ino == ino).unwrap();
3314
3315        // In-place Deleted entry (ino 100) -> status "deleted".
3316        let a = by(100);
3317        assert_eq!(
3318            deleted_xattr_value(a, "user.4n6.status").as_deref(),
3319            Some(b"deleted".as_ref())
3320        );
3321        // Recovered mtime surfaces as ISO-8601 UTC (mock mtime seconds = 200).
3322        assert_eq!(
3323            deleted_xattr_value(a, "user.4n6.macb.modified").as_deref(),
3324            Some(b"1970-01-01T00:03:20Z".as_ref())
3325        );
3326        // True orphan (ino 103) -> status "orphan".
3327        assert_eq!(
3328            deleted_xattr_value(by(103), "user.4n6.status").as_deref(),
3329            Some(b"orphan".as_ref())
3330        );
3331        // Unknown attribute -> None (getxattr replies ENODATA).
3332        assert!(deleted_xattr_value(a, "user.4n6.nope").is_none());
3333    }
3334
3335    // -----------------------------------------------------------------------
3336    // ADR 0008 v2 (d): recovered-deleted entries are COW-writable like live
3337    // files — a write copies up the recovered bytes, leaving the base untouched.
3338    // -----------------------------------------------------------------------
3339
3340    #[test]
3341    fn deleted_cow_base_yields_recovered_bytes() {
3342        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3343        fuse.ensure_deleted_cache();
3344        let cache = fuse.deleted_cache.borrow();
3345        let entries = cache.as_ref().expect("cache populated");
3346
3347        // A readable in-place delete (ino 100) copies up from its recovered
3348        // bytes — the write path is not forced read-only.
3349        let base = deleted_cow_base(entries, 100).expect("readable -> copy-up base");
3350        assert_eq!(base, vec![0xAB; 100]);
3351
3352        // An unreadable recovered entry (ino 104, gone.txt) has no base to copy
3353        // up — the write must fail loud, never fabricate an empty file.
3354        assert!(deleted_cow_base(entries, 104).is_none());
3355
3356        // Unknown inode -> no base.
3357        assert!(deleted_cow_base(entries, 999).is_none());
3358    }
3359}