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 Some(s) = session.as_ref() else {
910                    // cov:unreachable: gated by `has_session()` in the condition above.
911                    reply.error(libc::ENOENT);
912                    return;
913                };
914                let status = serde_json::json!({
915                    "image_path": s.metadata.image_path,
916                    "image_sha256": s.metadata.image_sha256,
917                    "created": s.metadata.created,
918                });
919                let data = serde_json::to_string_pretty(&status)
920                    .unwrap_or_default()
921                    .into_bytes();
922                let fuse_ino = metadata_ino(100);
923                let attr = virtual_file_attr(fuse_ino, data.len() as u64);
924                reply.entry(&TTL, &attr, 0);
925                return;
926            }
927            reply.error(libc::ENOENT);
928            return;
929        }
930
931        // rw/ namespace lookup.
932        if let Some(fs_parent) = self.rw_parent_to_fs(parent) {
933            // ADR 0008 v2: an in-place recovered deleted child resolves at its
934            // real name. Precomputed before any fs borrow below.
935            let deleted_hit = self.lookup_deleted_in_place(fs_parent, name_bytes);
936
937            // Check overlay created files first.
938            if let Some((id, counter, is_dir)) = self.find_created_by_name(fs_parent, name_bytes) {
939                let session = self.session.borrow();
940                let Some(session) = session.as_ref() else {
941                    // cov:unreachable: `find_created_by_name` only matches when
942                    // an overlay — and therefore a session — is present.
943                    reply.error(libc::ENOENT);
944                    return;
945                };
946                let entry = if is_dir {
947                    session.overlay.dirs.get(&id)
948                } else {
949                    session.overlay.created.get(&id)
950                };
951                if let Some(entry) = entry {
952                    let fuse_ino = rw_ino(counter + 9_000_000);
953                    let attr = Self::overlay_created_attr(fuse_ino, entry.size, is_dir);
954                    reply.entry(&TTL, &attr, 0);
955                    return;
956                }
957            }
958
959            // Check if name is a modified file.
960            {
961                let mut fs = self.fs.borrow_mut();
962                match fs.lookup(fs_parent, name_bytes) {
963                    Ok(Some(child_ino)) => {
964                        // Check whiteout.
965                        if self.is_whiteout(child_ino) {
966                            reply.error(libc::ENOENT);
967                            return;
968                        }
969
970                        // Check if modified in overlay.
971                        let session = self.session.borrow();
972                        let overlay_id = Self::modified_overlay_id(child_ino);
973                        if let Some(s) = session.as_ref() {
974                            if s.overlay.modified.contains_key(&child_ino) {
975                                if let Ok(meta) = fs.metadata(child_ino) {
976                                    let fuse_ino = rw_ino(child_ino);
977                                    let mut attr = fs_to_attr(fuse_ino, &meta);
978                                    if let Ok(data) = s.read_overlay_file(&overlay_id) {
979                                        attr.size = data.len() as u64;
980                                        attr.blocks = attr.size.div_ceil(512);
981                                    }
982                                    reply.entry(&TTL, &attr, 0);
983                                    return;
984                                }
985                                reply.error(libc::EIO);
986                                return;
987                            }
988                        }
989
990                        // Not modified, return attrs under rw/ namespace.
991                        match fs.metadata(child_ino) {
992                            Ok(meta) => {
993                                let fuse_ino = rw_ino(child_ino);
994                                reply.entry(&TTL, &fs_to_attr(fuse_ino, &meta), 0);
995                            }
996                            Err(_) => reply.error(libc::EIO),
997                        }
998                        return;
999                    }
1000                    Ok(None) => {
1001                        if let Some((fino, size)) = deleted_hit {
1002                            reply.entry(&TTL, &virtual_file_attr(fino, size), 0);
1003                            return;
1004                        }
1005                        reply.error(libc::ENOENT);
1006                        return;
1007                    }
1008                    Err(_) => {
1009                        reply.error(libc::EIO);
1010                        return;
1011                    }
1012                }
1013            }
1014        }
1015
1016        // ro/ namespace: the ro/ virtual dir maps to the fs root inode. In Raw
1017        // layout the FUSE root itself maps to the fs root (no ro/ wrapper).
1018        let fs_parent = match parent {
1019            FUSE_RO_INO => self.root_ino,
1020            FUSE_ROOT_INO if self.layout == crate::MountLayout::Raw => self.root_ino,
1021            _ => {
1022                if let InodeNamespace::Ro(ino) = decode_fuse_ino(parent) {
1023                    ino
1024                } else {
1025                    reply.error(libc::ENOENT);
1026                    return;
1027                }
1028            }
1029        };
1030
1031        // In-place recovered deleted child (checked before the fs borrow).
1032        let deleted_hit = self.lookup_deleted_in_place(fs_parent, name_bytes);
1033
1034        let mut fs = self.fs.borrow_mut();
1035        match fs.lookup(fs_parent, name_bytes) {
1036            Ok(Some(child_ino)) => match fs.metadata(child_ino) {
1037                Ok(meta) => {
1038                    let fuse_ino = ro_ino(child_ino);
1039                    reply.entry(&TTL, &fs_to_attr(fuse_ino, &meta), 0);
1040                }
1041                Err(_) => reply.error(libc::EIO),
1042            },
1043            Ok(None) => {
1044                if let Some((fino, size)) = deleted_hit {
1045                    reply.entry(&TTL, &virtual_file_attr(fino, size), 0);
1046                } else {
1047                    reply.error(libc::ENOENT);
1048                }
1049            }
1050            Err(_) => reply.error(libc::EIO),
1051        }
1052    }
1053
1054    fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
1055        // Virtual root.
1056        if ino == FUSE_ROOT_INO {
1057            reply.attr(&TTL, &virtual_dir_attr(FUSE_ROOT_INO));
1058            return;
1059        }
1060
1061        // Virtual top-level directories (incl. the top-level `$Orphans/`).
1062        if (FUSE_RO_INO..=FUSE_SESSION_INO).contains(&ino) || ino == FUSE_ORPHANS_INO {
1063            let mut attr = virtual_dir_attr(ino);
1064            if ino == FUSE_RW_INO && self.has_session() {
1065                attr.perm = 0o755;
1066            }
1067            reply.attr(&TTL, &attr);
1068            return;
1069        }
1070
1071        match decode_fuse_ino(ino) {
1072            InodeNamespace::Deleted(fs_ino) => {
1073                self.ensure_deleted_cache();
1074                let cache = self.deleted_cache.borrow();
1075                if let Some(entries) = cache.as_ref() {
1076                    if let Some(entry) = entries.iter().find(|e| e.fs_ino == fs_ino) {
1077                        // A copied-up delete reports its overlay size (ADR 0008
1078                        // v2 COW); otherwise the recovered size.
1079                        let mut size = entry.size;
1080                        let session = self.session.borrow();
1081                        if let Some(s) = session.as_ref() {
1082                            if s.overlay.modified.contains_key(&fs_ino) {
1083                                let overlay_id = Self::modified_overlay_id(fs_ino);
1084                                if let Ok(d) = s.read_overlay_file(&overlay_id) {
1085                                    size = d.len() as u64;
1086                                }
1087                            }
1088                        }
1089                        reply.attr(&TTL, &virtual_file_attr(ino, size));
1090                        return;
1091                    }
1092                }
1093                reply.error(libc::ENOENT);
1094            }
1095            InodeNamespace::Metadata(id) => {
1096                self.ensure_metadata_cache();
1097                let cache = self.metadata_cache.borrow();
1098                if let Some(mc) = cache.as_ref() {
1099                    match id {
1100                        1 => {
1101                            reply.attr(
1102                                &TTL,
1103                                &virtual_file_attr(ino, mc.superblock_json.len() as u64),
1104                            );
1105                        }
1106                        2 => {
1107                            reply.attr(
1108                                &TTL,
1109                                &virtual_file_attr(ino, mc.timeline_jsonl.len() as u64),
1110                            );
1111                        }
1112                        100 => {
1113                            // session/status.json
1114                            if self.has_session() {
1115                                let session = self.session.borrow();
1116                                let Some(s) = session.as_ref() else {
1117                                    // cov:unreachable: gated by `has_session()` above.
1118                                    reply.error(libc::ENOENT);
1119                                    return;
1120                                };
1121                                let status = serde_json::json!({
1122                                    "image_path": s.metadata.image_path,
1123                                    "image_sha256": s.metadata.image_sha256,
1124                                    "created": s.metadata.created,
1125                                });
1126                                let data =
1127                                    serde_json::to_string_pretty(&status).unwrap_or_default();
1128                                reply.attr(&TTL, &virtual_file_attr(ino, data.len() as u64));
1129                            } else {
1130                                reply.error(libc::ENOENT);
1131                            }
1132                        }
1133                        _ => reply.error(libc::ENOENT),
1134                    }
1135                } else {
1136                    reply.error(libc::ENOENT);
1137                }
1138            }
1139            InodeNamespace::Journal(seq) => {
1140                self.ensure_journal_cache();
1141                let cache = self.journal_cache.borrow();
1142                if let Some(entries) = cache.as_ref() {
1143                    if entries.iter().any(|e| e.sequence == seq) {
1144                        reply.attr(&TTL, &virtual_dir_attr(ino));
1145                    } else {
1146                        reply.error(libc::ENOENT);
1147                    }
1148                } else {
1149                    reply.error(libc::ENOENT);
1150                }
1151            }
1152            InodeNamespace::Unallocated(range_id) => {
1153                self.ensure_unallocated_cache();
1154                let cache = self.unallocated_cache.borrow();
1155                if let Some(entries) = cache.as_ref() {
1156                    if let Some(entry) = entries.get(range_id as usize) {
1157                        let block_size = self.fs.borrow().block_size();
1158                        let size = entry.length * block_size;
1159                        reply.attr(&TTL, &virtual_file_attr(ino, size));
1160                    } else {
1161                        reply.error(libc::ENOENT);
1162                    }
1163                } else {
1164                    reply.error(libc::ENOENT);
1165                }
1166            }
1167            InodeNamespace::Ro(fs_ino) => {
1168                let mut fs = self.fs.borrow_mut();
1169                match fs.metadata(fs_ino) {
1170                    Ok(meta) => reply.attr(&TTL, &fs_to_attr(ino, &meta)),
1171                    Err(_) => reply.error(libc::EIO),
1172                }
1173            }
1174            InodeNamespace::Rw(rw_id) => {
1175                // Check if this is a created overlay file (counter + 9_000_000).
1176                if rw_id >= 9_000_000 {
1177                    let counter = rw_id - 9_000_000;
1178                    let created_id = Self::created_overlay_id(counter);
1179                    let session = self.session.borrow();
1180                    if let Some(s) = session.as_ref() {
1181                        if let Some(entry) = s.overlay.created.get(&created_id) {
1182                            let attr = Self::overlay_created_attr(ino, entry.size, false);
1183                            reply.attr(&TTL, &attr);
1184                            return;
1185                        }
1186                        if let Some(entry) = s.overlay.dirs.get(&created_id) {
1187                            let attr = Self::overlay_created_attr(ino, entry.size, true);
1188                            reply.attr(&TTL, &attr);
1189                            return;
1190                        }
1191                    }
1192                    reply.error(libc::ENOENT);
1193                    return;
1194                }
1195
1196                // This is an fs inode viewed through rw/.
1197                let fs_ino = rw_id;
1198                let mut fs = self.fs.borrow_mut();
1199                match fs.metadata(fs_ino) {
1200                    Ok(meta) => {
1201                        let mut attr = fs_to_attr(ino, &meta);
1202                        // If modified, update size from overlay.
1203                        let session = self.session.borrow();
1204                        if let Some(s) = session.as_ref() {
1205                            let overlay_id = Self::modified_overlay_id(fs_ino);
1206                            if s.overlay.modified.contains_key(&fs_ino) {
1207                                if let Ok(data) = s.read_overlay_file(&overlay_id) {
1208                                    attr.size = data.len() as u64;
1209                                    attr.blocks = attr.size.div_ceil(512);
1210                                }
1211                            }
1212                        }
1213                        reply.attr(&TTL, &attr);
1214                    }
1215                    Err(_) => reply.error(libc::EIO),
1216                }
1217            }
1218            _ => reply.error(libc::ENOENT),
1219        }
1220    }
1221
1222    /// Read one extended attribute. Only recovered-deleted/orphan entries carry
1223    /// the `user.4n6.*` marking (ADR 0008 v2); live files and virtual nodes have
1224    /// none, so they reply `ENODATA`. Follows the FUSE size-probe protocol:
1225    /// `size == 0` returns the value length; otherwise the bytes (or `ERANGE`).
1226    fn getxattr(&mut self, _req: &Request, ino: u64, name: &OsStr, size: u32, reply: ReplyXattr) {
1227        let value = if let InodeNamespace::Deleted(fs_ino) = decode_fuse_ino(ino) {
1228            self.ensure_deleted_cache();
1229            let cache = self.deleted_cache.borrow();
1230            let attr_name = name.to_str();
1231            cache.as_ref().and_then(|entries| {
1232                let n = attr_name?;
1233                let e = entries.iter().find(|e| e.fs_ino == fs_ino)?;
1234                deleted_xattr_value(e, n)
1235            })
1236        } else {
1237            None
1238        };
1239        match value {
1240            Some(v) => {
1241                if size == 0 {
1242                    reply.size(v.len() as u32);
1243                } else if (v.len() as u32) <= size {
1244                    reply.data(&v);
1245                } else {
1246                    reply.error(libc::ERANGE);
1247                }
1248            }
1249            None => reply.error(libc::ENODATA),
1250        }
1251    }
1252
1253    /// List the extended attribute names on an entry. Recovered-deleted/orphan
1254    /// entries expose the `user.4n6.*` marking schema; everything else lists
1255    /// nothing. Names are NUL-separated per the FUSE `listxattr` contract.
1256    fn listxattr(&mut self, _req: &Request, ino: u64, size: u32, reply: ReplyXattr) {
1257        let mut buf: Vec<u8> = Vec::new();
1258        if let InodeNamespace::Deleted(fs_ino) = decode_fuse_ino(ino) {
1259            self.ensure_deleted_cache();
1260            let cache = self.deleted_cache.borrow();
1261            let present = cache
1262                .as_ref()
1263                .is_some_and(|entries| entries.iter().any(|e| e.fs_ino == fs_ino));
1264            if present {
1265                for n in deleted_xattr_names() {
1266                    buf.extend_from_slice(n.as_bytes());
1267                    buf.push(0);
1268                }
1269            }
1270        }
1271        if size == 0 {
1272            reply.size(buf.len() as u32);
1273        } else if (buf.len() as u32) <= size {
1274            reply.data(&buf);
1275        } else {
1276            reply.error(libc::ERANGE);
1277        }
1278    }
1279
1280    fn readdir(
1281        &mut self,
1282        _req: &Request,
1283        ino: u64,
1284        _fh: u64,
1285        offset: i64,
1286        mut reply: ReplyDirectory,
1287    ) {
1288        let offset = offset as usize;
1289
1290        // Root directory: virtual dirs (DiskOverlay) or the ForensicFs tree
1291        // directly (Raw) — both via the tested root_children() decision.
1292        if ino == FUSE_ROOT_INO {
1293            let has_orphans = self.cache_orphans_present();
1294            let mut entries: Vec<(u64, FileType, String)> = vec![
1295                (FUSE_ROOT_INO, FileType::Directory, ".".to_string()),
1296                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1297            ];
1298            {
1299                let mut fs = self.fs.borrow_mut();
1300                let Ok(children) =
1301                    root_children(self.layout, &mut **fs, self.root_ino, has_orphans)
1302                else {
1303                    reply.error(libc::EIO);
1304                    return;
1305                };
1306                for (fino, name, kind) in children {
1307                    entries.push((fino, kind, String::from_utf8_lossy(&name).into_owned()));
1308                }
1309            }
1310            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1311                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1312                    break;
1313                }
1314            }
1315            reply.ok();
1316            return;
1317        }
1318
1319        // rw/ namespace readdir.
1320        if let Some(fs_dir_ino) = match ino {
1321            FUSE_RW_INO => Some(self.root_ino),
1322            _ => match decode_fuse_ino(ino) {
1323                InodeNamespace::Rw(rw_id) if rw_id < 9_000_000 => Some(rw_id),
1324                _ => None,
1325            },
1326        } {
1327            // ADR 0008 v2: recovered deleted children render in-place beside the
1328            // live siblings. Computed before borrowing fs (ensure_deleted_cache
1329            // takes its own fs borrow).
1330            self.ensure_deleted_cache();
1331            let injected: Vec<(u64, FileType, String)> = {
1332                let cache = self.deleted_cache.borrow();
1333                cache.as_ref().map_or_else(Vec::new, |c| {
1334                    deleted_in_place_children(c, fs_dir_ino)
1335                        .into_iter()
1336                        .map(|(dino, name)| (dino, FileType::RegularFile, name))
1337                        .collect()
1338                })
1339            };
1340            let mut fs = self.fs.borrow_mut();
1341            match fs.read_dir(fs_dir_ino) {
1342                Ok(entries) => {
1343                    let session = self.session.borrow();
1344                    let mut fuse_entries: Vec<(u64, FileType, String)> = Vec::new();
1345
1346                    for e in &entries {
1347                        let name = e.name_str();
1348                        let child_ino = e.inode;
1349
1350                        // Filter out whiteouts.
1351                        if let Some(s) = session.as_ref() {
1352                            if name != "." && name != ".." && s.overlay.deleted.contains(&child_ino)
1353                            {
1354                                continue;
1355                            }
1356                        }
1357
1358                        let fuse_ino = if name == "." || name == ".." {
1359                            if fs_dir_ino == self.root_ino && name == "." {
1360                                FUSE_RW_INO
1361                            } else if fs_dir_ino == self.root_ino && name == ".." {
1362                                FUSE_ROOT_INO
1363                            } else {
1364                                rw_ino(child_ino)
1365                            }
1366                        } else {
1367                            rw_ino(child_ino)
1368                        };
1369                        let kind = fs_file_type_to_fuse(e.file_type);
1370                        fuse_entries.push((fuse_ino, kind, name));
1371                    }
1372
1373                    // Add overlay created entries for this directory.
1374                    if let Some(s) = session.as_ref() {
1375                        for (id, entry) in &s.overlay.created {
1376                            if entry.parent_ino == fs_dir_ino {
1377                                if let Some(counter) =
1378                                    id.strip_prefix("new_").and_then(|s| s.parse::<u64>().ok())
1379                                {
1380                                    let fuse_ino = rw_ino(counter + 9_000_000);
1381                                    fuse_entries.push((
1382                                        fuse_ino,
1383                                        FileType::RegularFile,
1384                                        entry.name.clone(),
1385                                    ));
1386                                }
1387                            }
1388                        }
1389                        for (id, entry) in &s.overlay.dirs {
1390                            if entry.parent_ino == fs_dir_ino {
1391                                if let Some(counter) =
1392                                    id.strip_prefix("new_").and_then(|s| s.parse::<u64>().ok())
1393                                {
1394                                    let fuse_ino = rw_ino(counter + 9_000_000);
1395                                    fuse_entries.push((
1396                                        fuse_ino,
1397                                        FileType::Directory,
1398                                        entry.name.clone(),
1399                                    ));
1400                                }
1401                            }
1402                        }
1403                    }
1404
1405                    // Recovered deleted children, in-place at their real name.
1406                    fuse_entries.extend(injected);
1407
1408                    for (i, (entry_ino, kind, name)) in fuse_entries.iter().enumerate().skip(offset)
1409                    {
1410                        if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1411                            break;
1412                        }
1413                    }
1414                    reply.ok();
1415                }
1416                Err(_) => reply.error(libc::EIO),
1417            }
1418            return;
1419        }
1420
1421        // $Orphans/ readdir (top-level, ADR 0008 v2): the unplaceable recovered
1422        // entries (true orphans, live-name collisions, older same-name
1423        // deletes), disambiguated by mtime + record id.
1424        if ino == FUSE_ORPHANS_INO {
1425            self.ensure_deleted_cache();
1426            let cache = self.deleted_cache.borrow();
1427            let mut entries: Vec<(u64, FileType, String)> = vec![
1428                (FUSE_ORPHANS_INO, FileType::Directory, ".".to_string()),
1429                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1430            ];
1431            if let Some(cached) = cache.as_ref() {
1432                for entry in cached.iter().filter(|e| e.orphan) {
1433                    entries.push((
1434                        deleted_ino(entry.fs_ino),
1435                        FileType::RegularFile,
1436                        entry.name.clone(),
1437                    ));
1438                }
1439            }
1440            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1441                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1442                    break;
1443                }
1444            }
1445            reply.ok();
1446            return;
1447        }
1448
1449        // journal/ readdir
1450        if ino == FUSE_JOURNAL_INO {
1451            self.ensure_journal_cache();
1452            let cache = self.journal_cache.borrow();
1453            let mut entries: Vec<(u64, FileType, String)> = vec![
1454                (FUSE_JOURNAL_INO, FileType::Directory, ".".to_string()),
1455                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1456            ];
1457            if let Some(cached) = cache.as_ref() {
1458                for entry in cached {
1459                    entries.push((
1460                        journal_ino(entry.sequence),
1461                        FileType::Directory,
1462                        entry.name.clone(),
1463                    ));
1464                }
1465            }
1466            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1467                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1468                    break;
1469                }
1470            }
1471            reply.ok();
1472            return;
1473        }
1474
1475        // journal/txn_N/ readdir (empty directory for now)
1476        if let InodeNamespace::Journal(_seq) = decode_fuse_ino(ino) {
1477            if offset == 0 {
1478                let _ = reply.add(ino, 1, FileType::Directory, ".");
1479                let _ = reply.add(FUSE_JOURNAL_INO, 2, FileType::Directory, "..");
1480            }
1481            reply.ok();
1482            return;
1483        }
1484
1485        // metadata/ readdir
1486        if ino == FUSE_METADATA_INO {
1487            self.ensure_metadata_cache();
1488            let cache = self.metadata_cache.borrow();
1489            let mut entries: Vec<(u64, FileType, String)> = vec![
1490                (FUSE_METADATA_INO, FileType::Directory, ".".to_string()),
1491                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1492            ];
1493            if cache.is_some() {
1494                entries.push((
1495                    metadata_ino(1),
1496                    FileType::RegularFile,
1497                    "superblock.json".to_string(),
1498                ));
1499                entries.push((
1500                    metadata_ino(2),
1501                    FileType::RegularFile,
1502                    "timeline.jsonl".to_string(),
1503                ));
1504            }
1505            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1506                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1507                    break;
1508                }
1509            }
1510            reply.ok();
1511            return;
1512        }
1513
1514        // unallocated/ readdir
1515        if ino == FUSE_UNALLOCATED_INO {
1516            self.ensure_unallocated_cache();
1517            let cache = self.unallocated_cache.borrow();
1518            let mut entries: Vec<(u64, FileType, String)> = vec![
1519                (FUSE_UNALLOCATED_INO, FileType::Directory, ".".to_string()),
1520                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1521            ];
1522            if let Some(cached) = cache.as_ref() {
1523                for (i, entry) in cached.iter().enumerate() {
1524                    entries.push((
1525                        unallocated_ino(i as u64),
1526                        FileType::RegularFile,
1527                        entry.name.clone(),
1528                    ));
1529                }
1530            }
1531            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1532                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1533                    break;
1534                }
1535            }
1536            reply.ok();
1537            return;
1538        }
1539
1540        // session/ readdir
1541        if ino == FUSE_SESSION_INO {
1542            let mut entries: Vec<(u64, FileType, String)> = vec![
1543                (FUSE_SESSION_INO, FileType::Directory, ".".to_string()),
1544                (FUSE_ROOT_INO, FileType::Directory, "..".to_string()),
1545            ];
1546            if self.has_session() {
1547                entries.push((
1548                    metadata_ino(100),
1549                    FileType::RegularFile,
1550                    "status.json".to_string(),
1551                ));
1552            }
1553            for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset) {
1554                if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1555                    break;
1556                }
1557            }
1558            reply.ok();
1559            return;
1560        }
1561
1562        // Determine the fs inode for this directory.
1563        let fs_dir_ino = match ino {
1564            FUSE_RO_INO => self.root_ino,
1565            _ => {
1566                if let InodeNamespace::Ro(fs_ino) = decode_fuse_ino(ino) {
1567                    fs_ino
1568                } else {
1569                    reply.error(libc::ENOENT);
1570                    return;
1571                }
1572            }
1573        };
1574
1575        // ADR 0008 v2: recovered deleted children also render in-place in the
1576        // read-only view of the main tree.
1577        self.ensure_deleted_cache();
1578        let injected: Vec<(u64, FileType, String)> = {
1579            let cache = self.deleted_cache.borrow();
1580            cache.as_ref().map_or_else(Vec::new, |c| {
1581                deleted_in_place_children(c, fs_dir_ino)
1582                    .into_iter()
1583                    .map(|(dino, name)| (dino, FileType::RegularFile, name))
1584                    .collect()
1585            })
1586        };
1587
1588        let mut fs = self.fs.borrow_mut();
1589        match fs.read_dir(fs_dir_ino) {
1590            Ok(entries) => {
1591                let root_ino = self.root_ino;
1592                let mut fuse_entries: Vec<(u64, FileType, String)> = entries
1593                    .iter()
1594                    .map(|e| {
1595                        let name = e.name_str();
1596                        let fuse_ino = if name == "." || name == ".." {
1597                            if fs_dir_ino == root_ino && name == "." {
1598                                FUSE_RO_INO
1599                            } else if fs_dir_ino == root_ino && name == ".." {
1600                                FUSE_ROOT_INO
1601                            } else {
1602                                ro_ino(e.inode)
1603                            }
1604                        } else {
1605                            ro_ino(e.inode)
1606                        };
1607                        let kind = fs_file_type_to_fuse(e.file_type);
1608                        (fuse_ino, kind, name)
1609                    })
1610                    .collect();
1611
1612                fuse_entries.extend(injected);
1613
1614                for (i, (entry_ino, kind, name)) in fuse_entries.iter().enumerate().skip(offset) {
1615                    if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1616                        break;
1617                    }
1618                }
1619                reply.ok();
1620            }
1621            Err(_) => reply.error(libc::EIO),
1622        }
1623    }
1624
1625    fn read(
1626        &mut self,
1627        _req: &Request,
1628        ino: u64,
1629        _fh: u64,
1630        offset: i64,
1631        size: u32,
1632        _flags: i32,
1633        _lock_owner: Option<u64>,
1634        reply: ReplyData,
1635    ) {
1636        match decode_fuse_ino(ino) {
1637            InodeNamespace::Deleted(fs_ino) => {
1638                // A copied-up recovered-deleted file reads from the overlay
1639                // (ADR 0008 v2 COW); otherwise from the recovered-bytes cache.
1640                {
1641                    let session = self.session.borrow();
1642                    if let Some(s) = session.as_ref() {
1643                        if s.overlay.modified.contains_key(&fs_ino) {
1644                            let overlay_id = Self::modified_overlay_id(fs_ino);
1645                            if let Ok(d) = s.read_overlay_file(&overlay_id) {
1646                                let off = offset as usize;
1647                                if off >= d.len() {
1648                                    reply.data(&[]);
1649                                } else {
1650                                    let end = (off + size as usize).min(d.len());
1651                                    reply.data(&d[off..end]);
1652                                }
1653                                return;
1654                            }
1655                        }
1656                    }
1657                }
1658                self.ensure_deleted_cache();
1659                let cache = self.deleted_cache.borrow();
1660                if let Some(entries) = cache.as_ref() {
1661                    if let Some(entry) = entries.iter().find(|e| e.fs_ino == fs_ino) {
1662                        // Unreadable recovered content is a loud read error, not
1663                        // a fabricated 0-byte success.
1664                        if !entry.readable {
1665                            reply.error(libc::EIO);
1666                            return;
1667                        }
1668                        let off = offset as usize;
1669                        if off >= entry.data.len() {
1670                            reply.data(&[]);
1671                        } else {
1672                            let end = (off + size as usize).min(entry.data.len());
1673                            reply.data(&entry.data[off..end]);
1674                        }
1675                        return;
1676                    }
1677                }
1678                reply.error(libc::ENOENT);
1679            }
1680            InodeNamespace::Metadata(id) => {
1681                self.ensure_metadata_cache();
1682                let data = match id {
1683                    1 => {
1684                        let cache = self.metadata_cache.borrow();
1685                        cache.as_ref().map(|mc| mc.superblock_json.clone())
1686                    }
1687                    2 => {
1688                        let cache = self.metadata_cache.borrow();
1689                        cache.as_ref().map(|mc| mc.timeline_jsonl.clone())
1690                    }
1691                    100 => {
1692                        // session/status.json
1693                        let session = self.session.borrow();
1694                        session.as_ref().map(|s| {
1695                            let status = serde_json::json!({
1696                                "image_path": s.metadata.image_path,
1697                                "image_sha256": s.metadata.image_sha256,
1698                                "created": s.metadata.created,
1699                            });
1700                            serde_json::to_string_pretty(&status)
1701                                .unwrap_or_default()
1702                                .into_bytes()
1703                        })
1704                    }
1705                    _ => None,
1706                };
1707                match data {
1708                    Some(buf) => {
1709                        let off = offset as usize;
1710                        if off >= buf.len() {
1711                            reply.data(&[]);
1712                        } else {
1713                            let end = (off + size as usize).min(buf.len());
1714                            reply.data(&buf[off..end]);
1715                        }
1716                    }
1717                    None => reply.error(libc::ENOENT),
1718                }
1719            }
1720            InodeNamespace::Unallocated(range_id) => {
1721                self.ensure_unallocated_cache();
1722                let range_info = {
1723                    let cache = self.unallocated_cache.borrow();
1724                    cache.as_ref().and_then(|entries| {
1725                        entries.get(range_id as usize).map(|e| FsBlockRange {
1726                            start: e.start,
1727                            length: e.length,
1728                        })
1729                    })
1730                };
1731                match range_info {
1732                    Some(range) => {
1733                        let mut fs = self.fs.borrow_mut();
1734                        match fs.read_unallocated(&range) {
1735                            Ok(data) => {
1736                                let off = offset as usize;
1737                                if off >= data.len() {
1738                                    reply.data(&[]);
1739                                } else {
1740                                    let end = (off + size as usize).min(data.len());
1741                                    reply.data(&data[off..end]);
1742                                }
1743                            }
1744                            Err(_) => reply.error(libc::EIO),
1745                        }
1746                    }
1747                    None => reply.error(libc::ENOENT),
1748                }
1749            }
1750            InodeNamespace::Ro(fs_ino) => {
1751                let mut fs = self.fs.borrow_mut();
1752                match fs.read_file_range(fs_ino, offset as u64, u64::from(size)) {
1753                    Ok(data) => reply.data(&data),
1754                    Err(_) => reply.error(libc::EIO),
1755                }
1756            }
1757            InodeNamespace::Rw(rw_id) => {
1758                // Check if this is a created overlay file.
1759                if rw_id >= 9_000_000 {
1760                    let counter = rw_id - 9_000_000;
1761                    let created_id = Self::created_overlay_id(counter);
1762                    let session = self.session.borrow();
1763                    if let Some(s) = session.as_ref() {
1764                        if s.overlay.created.contains_key(&created_id)
1765                            || s.overlay.dirs.contains_key(&created_id)
1766                        {
1767                            if let Ok(data) = s.read_overlay_file(&created_id) {
1768                                let off = offset as usize;
1769                                let end = (off + size as usize).min(data.len());
1770                                if off >= data.len() {
1771                                    reply.data(&[]);
1772                                } else {
1773                                    reply.data(&data[off..end]);
1774                                }
1775                                return;
1776                            }
1777                            reply.error(libc::EIO);
1778                            return;
1779                        }
1780                    }
1781                    reply.error(libc::ENOENT);
1782                    return;
1783                }
1784
1785                // fs inode under rw/.
1786                let fs_ino = rw_id;
1787                // Check if modified in overlay.
1788                let session = self.session.borrow();
1789                if let Some(s) = session.as_ref() {
1790                    let overlay_id = Self::modified_overlay_id(fs_ino);
1791                    if s.overlay.modified.contains_key(&fs_ino) {
1792                        if let Ok(data) = s.read_overlay_file(&overlay_id) {
1793                            let off = offset as usize;
1794                            let end = (off + size as usize).min(data.len());
1795                            if off >= data.len() {
1796                                reply.data(&[]);
1797                            } else {
1798                                reply.data(&data[off..end]);
1799                            }
1800                            return;
1801                        }
1802                        reply.error(libc::EIO);
1803                        return;
1804                    }
1805                }
1806                drop(session);
1807
1808                // Fall back to underlying fs.
1809                let mut fs = self.fs.borrow_mut();
1810                match fs.read_file_range(fs_ino, offset as u64, u64::from(size)) {
1811                    Ok(data) => reply.data(&data),
1812                    Err(_) => reply.error(libc::EIO),
1813                }
1814            }
1815            _ => {
1816                reply.error(libc::ENOENT);
1817            }
1818        }
1819    }
1820
1821    fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) {
1822        let fs_ino = match decode_fuse_ino(ino) {
1823            InodeNamespace::Ro(fs_ino) => fs_ino,
1824            InodeNamespace::Rw(rw_id) if rw_id < 9_000_000 => rw_id,
1825            _ => {
1826                reply.error(libc::ENOENT);
1827                return;
1828            }
1829        };
1830
1831        let mut fs = self.fs.borrow_mut();
1832        match fs.read_link(fs_ino) {
1833            Ok(target) => reply.data(&target),
1834            Err(_) => reply.error(libc::EIO),
1835        }
1836    }
1837
1838    fn write(
1839        &mut self,
1840        _req: &Request,
1841        ino: u64,
1842        _fh: u64,
1843        offset: i64,
1844        data: &[u8],
1845        _write_flags: u32,
1846        _flags: i32,
1847        _lock_owner: Option<u64>,
1848        reply: ReplyWrite,
1849    ) {
1850        if !self.has_session() {
1851            reply.error(libc::EROFS);
1852            return;
1853        }
1854
1855        match decode_fuse_ino(ino) {
1856            InodeNamespace::Rw(rw_id) => {
1857                // Created overlay file.
1858                if rw_id >= 9_000_000 {
1859                    let counter = rw_id - 9_000_000;
1860                    let created_id = Self::created_overlay_id(counter);
1861                    let mut session = self.session.borrow_mut();
1862                    let Some(s) = session.as_mut() else {
1863                        // cov:unreachable: this operation returns EROFS above
1864                        // when `has_session()` is false.
1865                        reply.error(libc::EROFS);
1866                        return;
1867                    };
1868
1869                    let is_known = s.overlay.created.contains_key(&created_id)
1870                        || s.overlay.dirs.contains_key(&created_id);
1871                    if !is_known {
1872                        reply.error(libc::ENOENT);
1873                        return;
1874                    }
1875
1876                    let mut buf = s.read_overlay_file(&created_id).unwrap_or_default();
1877                    let off = offset as usize;
1878                    let end = off + data.len();
1879                    if end > buf.len() {
1880                        buf.resize(end, 0);
1881                    }
1882                    buf[off..end].copy_from_slice(data);
1883
1884                    if s.write_overlay_file(&created_id, &buf).is_err() {
1885                        reply.error(libc::EIO);
1886                        return;
1887                    }
1888
1889                    if let Some(entry) = s.overlay.created.get_mut(&created_id) {
1890                        entry.size = buf.len() as u64;
1891                    }
1892                    if let Some(entry) = s.overlay.dirs.get_mut(&created_id) {
1893                        entry.size = buf.len() as u64;
1894                    }
1895
1896                    if s.save().is_err() {
1897                        reply.error(libc::EIO);
1898                        return;
1899                    }
1900
1901                    reply.written(data.len() as u32);
1902                    return;
1903                }
1904
1905                // Existing fs inode under rw/ — COW on first write.
1906                let fs_ino = rw_id;
1907                let overlay_id = Self::modified_overlay_id(fs_ino);
1908
1909                let mut session = self.session.borrow_mut();
1910                let Some(s) = session.as_mut() else {
1911                    // cov:unreachable: this operation returns EROFS above when
1912                    // `has_session()` is false.
1913                    reply.error(libc::EROFS);
1914                    return;
1915                };
1916
1917                if !s.overlay.modified.contains_key(&fs_ino) {
1918                    let mut fs = self.fs.borrow_mut();
1919                    let Ok(original) = fs.read_file(fs_ino) else {
1920                        reply.error(libc::EIO);
1921                        return;
1922                    };
1923                    if s.write_overlay_file(&overlay_id, &original).is_err() {
1924                        reply.error(libc::EIO);
1925                        return;
1926                    }
1927                    s.overlay.modified.insert(fs_ino, overlay_id.clone());
1928                }
1929
1930                let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
1931                let off = offset as usize;
1932                let end = off + data.len();
1933                if end > buf.len() {
1934                    buf.resize(end, 0);
1935                }
1936                buf[off..end].copy_from_slice(data);
1937
1938                if s.write_overlay_file(&overlay_id, &buf).is_err() {
1939                    reply.error(libc::EIO);
1940                    return;
1941                }
1942
1943                if s.save().is_err() {
1944                    reply.error(libc::EIO);
1945                    return;
1946                }
1947
1948                reply.written(data.len() as u32);
1949            }
1950            // ADR 0008 v2: an in-place recovered-deleted file is COW-writable
1951            // like a live file. First write copies up its recovered bytes onto
1952            // the overlay (keyed by fs inode); the recovered base is untouched.
1953            InodeNamespace::Deleted(fs_ino) => {
1954                self.ensure_deleted_cache();
1955                let overlay_id = Self::modified_overlay_id(fs_ino);
1956                let mut session = self.session.borrow_mut();
1957                let Some(s) = session.as_mut() else {
1958                    // cov:unreachable: this operation returns EROFS above when
1959                    // `has_session()` is false.
1960                    reply.error(libc::EROFS);
1961                    return;
1962                };
1963
1964                if !s.overlay.modified.contains_key(&fs_ino) {
1965                    let base = {
1966                        let cache = self.deleted_cache.borrow();
1967                        cache.as_ref().and_then(|e| deleted_cow_base(e, fs_ino))
1968                    };
1969                    // Unreadable recovered content cannot be copied up — fail
1970                    // loud, never fabricate an empty base.
1971                    let Some(base) = base else {
1972                        reply.error(libc::EIO);
1973                        return;
1974                    };
1975                    if s.write_overlay_file(&overlay_id, &base).is_err() {
1976                        reply.error(libc::EIO);
1977                        return;
1978                    }
1979                    s.overlay.modified.insert(fs_ino, overlay_id.clone());
1980                }
1981
1982                let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
1983                let off = offset as usize;
1984                let end = off + data.len();
1985                if end > buf.len() {
1986                    buf.resize(end, 0);
1987                }
1988                buf[off..end].copy_from_slice(data);
1989
1990                if s.write_overlay_file(&overlay_id, &buf).is_err() {
1991                    reply.error(libc::EIO);
1992                    return;
1993                }
1994                if s.save().is_err() {
1995                    reply.error(libc::EIO);
1996                    return;
1997                }
1998                reply.written(data.len() as u32);
1999            }
2000            _ => reply.error(libc::EROFS),
2001        }
2002    }
2003
2004    fn create(
2005        &mut self,
2006        _req: &Request,
2007        parent: u64,
2008        name: &OsStr,
2009        _mode: u32,
2010        _umask: u32,
2011        _flags: i32,
2012        reply: ReplyCreate,
2013    ) {
2014        if !self.has_session() {
2015            reply.error(libc::EROFS);
2016            return;
2017        }
2018
2019        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
2020            reply.error(libc::EROFS);
2021            return;
2022        };
2023
2024        let Some(name_s) = name.to_str() else {
2025            reply.error(libc::EINVAL);
2026            return;
2027        };
2028        let name_str = name_s.to_string();
2029
2030        let counter = self.alloc_overlay_ino();
2031        let created_id = Self::created_overlay_id(counter);
2032        let fuse_ino = rw_ino(counter + 9_000_000);
2033
2034        let mut session = self.session.borrow_mut();
2035        let Some(s) = session.as_mut() else {
2036            // cov:unreachable: this operation returns EROFS above when
2037            // `has_session()` is false.
2038            reply.error(libc::EROFS);
2039            return;
2040        };
2041
2042        if s.write_overlay_file(&created_id, &[]).is_err() {
2043            reply.error(libc::EIO);
2044            return;
2045        }
2046
2047        s.overlay.created.insert(
2048            created_id,
2049            crate::session::OverlayEntry {
2050                parent_ino: fs_parent,
2051                name: name_str,
2052                size: 0,
2053            },
2054        );
2055
2056        if s.save().is_err() {
2057            reply.error(libc::EIO);
2058            return;
2059        }
2060
2061        let attr = Self::overlay_created_attr(fuse_ino, 0, false);
2062        reply.created(&TTL, &attr, 0, 0, 0);
2063    }
2064
2065    fn mkdir(
2066        &mut self,
2067        _req: &Request,
2068        parent: u64,
2069        name: &OsStr,
2070        _mode: u32,
2071        _umask: u32,
2072        reply: ReplyEntry,
2073    ) {
2074        if !self.has_session() {
2075            reply.error(libc::EROFS);
2076            return;
2077        }
2078
2079        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
2080            reply.error(libc::EROFS);
2081            return;
2082        };
2083
2084        let Some(name_s) = name.to_str() else {
2085            reply.error(libc::EINVAL);
2086            return;
2087        };
2088        let name_str = name_s.to_string();
2089
2090        let counter = self.alloc_overlay_ino();
2091        let created_id = Self::created_overlay_id(counter);
2092        let fuse_ino = rw_ino(counter + 9_000_000);
2093
2094        let mut session = self.session.borrow_mut();
2095        let Some(s) = session.as_mut() else {
2096            // cov:unreachable: this operation returns EROFS above when
2097            // `has_session()` is false.
2098            reply.error(libc::EROFS);
2099            return;
2100        };
2101
2102        s.overlay.dirs.insert(
2103            created_id,
2104            crate::session::OverlayEntry {
2105                parent_ino: fs_parent,
2106                name: name_str,
2107                size: 0,
2108            },
2109        );
2110
2111        if s.save().is_err() {
2112            reply.error(libc::EIO);
2113            return;
2114        }
2115
2116        let attr = Self::overlay_created_attr(fuse_ino, 0, true);
2117        reply.entry(&TTL, &attr, 0);
2118    }
2119
2120    fn unlink(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEmpty) {
2121        if !self.has_session() {
2122            reply.error(libc::EROFS);
2123            return;
2124        }
2125
2126        let Some(fs_parent) = self.rw_parent_to_fs(parent) else {
2127            reply.error(libc::EROFS);
2128            return;
2129        };
2130
2131        let name_bytes = name.as_encoded_bytes();
2132
2133        // Check if it's a created overlay file first.
2134        if let Some((id, _counter, _is_dir)) = self.find_created_by_name(fs_parent, name_bytes) {
2135            let mut session = self.session.borrow_mut();
2136            let Some(s) = session.as_mut() else {
2137                // cov:unreachable: this operation returns EROFS above when
2138                // `has_session()` is false.
2139                reply.error(libc::EROFS);
2140                return;
2141            };
2142            s.overlay.created.remove(&id);
2143            s.overlay.dirs.remove(&id);
2144            let _ = std::fs::remove_file(s.overlay_file_path(&id));
2145            if s.save().is_err() {
2146                reply.error(libc::EIO);
2147                return;
2148            }
2149            reply.ok();
2150            return;
2151        }
2152
2153        // Look up the fs inode and add whiteout.
2154        let mut fs = self.fs.borrow_mut();
2155        match fs.lookup(fs_parent, name_bytes) {
2156            Ok(Some(child_ino)) => {
2157                let mut session = self.session.borrow_mut();
2158                let Some(s) = session.as_mut() else {
2159                    // cov:unreachable: this operation returns EROFS above when
2160                    // `has_session()` is false.
2161                    reply.error(libc::EROFS);
2162                    return;
2163                };
2164                if !s.overlay.deleted.contains(&child_ino) {
2165                    s.overlay.deleted.push(child_ino);
2166                }
2167                if s.save().is_err() {
2168                    reply.error(libc::EIO);
2169                    return;
2170                }
2171                reply.ok();
2172            }
2173            Ok(None) => reply.error(libc::ENOENT),
2174            Err(_) => reply.error(libc::EIO),
2175        }
2176    }
2177
2178    fn rmdir(&mut self, req: &Request, parent: u64, name: &OsStr, reply: ReplyEmpty) {
2179        self.unlink(req, parent, name, reply);
2180    }
2181
2182    #[allow(clippy::too_many_arguments)]
2183    fn setattr(
2184        &mut self,
2185        _req: &Request,
2186        ino: u64,
2187        _mode: Option<u32>,
2188        _uid: Option<u32>,
2189        _gid: Option<u32>,
2190        size: Option<u64>,
2191        _atime: Option<TimeOrNow>,
2192        _mtime: Option<TimeOrNow>,
2193        _ctime: Option<SystemTime>,
2194        _fh: Option<u64>,
2195        _crtime: Option<SystemTime>,
2196        _chgtime: Option<SystemTime>,
2197        _bkuptime: Option<SystemTime>,
2198        _flags: Option<u32>,
2199        reply: ReplyAttr,
2200    ) {
2201        match decode_fuse_ino(ino) {
2202            InodeNamespace::Rw(rw_id) => {
2203                if !self.has_session() {
2204                    reply.error(libc::EROFS);
2205                    return;
2206                }
2207
2208                // Handle truncate (size change).
2209                if let Some(new_size) = size {
2210                    // Created overlay file.
2211                    if rw_id >= 9_000_000 {
2212                        let counter = rw_id - 9_000_000;
2213                        let created_id = Self::created_overlay_id(counter);
2214                        let mut session = self.session.borrow_mut();
2215                        let Some(s) = session.as_mut() else {
2216                            // cov:unreachable: this operation returns EROFS
2217                            // above when `has_session()` is false.
2218                            reply.error(libc::EROFS);
2219                            return;
2220                        };
2221
2222                        let mut buf = s.read_overlay_file(&created_id).unwrap_or_default();
2223                        buf.resize(new_size as usize, 0);
2224                        if s.write_overlay_file(&created_id, &buf).is_err() {
2225                            reply.error(libc::EIO);
2226                            return;
2227                        }
2228
2229                        if let Some(entry) = s.overlay.created.get_mut(&created_id) {
2230                            entry.size = new_size;
2231                        }
2232                        if let Some(entry) = s.overlay.dirs.get_mut(&created_id) {
2233                            entry.size = new_size;
2234                        }
2235                        if s.save().is_err() {
2236                            reply.error(libc::EIO);
2237                            return;
2238                        }
2239
2240                        let attr = Self::overlay_created_attr(ino, new_size, false);
2241                        reply.attr(&TTL, &attr);
2242                        return;
2243                    }
2244
2245                    // Existing fs inode — COW then truncate.
2246                    let fs_ino = rw_id;
2247                    let overlay_id = Self::modified_overlay_id(fs_ino);
2248
2249                    let mut session = self.session.borrow_mut();
2250                    let Some(s) = session.as_mut() else {
2251                        // cov:unreachable: this operation returns EROFS above
2252                        // when `has_session()` is false.
2253                        reply.error(libc::EROFS);
2254                        return;
2255                    };
2256
2257                    if !s.overlay.modified.contains_key(&fs_ino) {
2258                        let mut fs = self.fs.borrow_mut();
2259                        let Ok(original) = fs.read_file(fs_ino) else {
2260                            reply.error(libc::EIO);
2261                            return;
2262                        };
2263                        if s.write_overlay_file(&overlay_id, &original).is_err() {
2264                            reply.error(libc::EIO);
2265                            return;
2266                        }
2267                        s.overlay.modified.insert(fs_ino, overlay_id.clone());
2268                    }
2269
2270                    let mut buf = s.read_overlay_file(&overlay_id).unwrap_or_default();
2271                    buf.resize(new_size as usize, 0);
2272                    if s.write_overlay_file(&overlay_id, &buf).is_err() {
2273                        reply.error(libc::EIO);
2274                        return;
2275                    }
2276                    if s.save().is_err() {
2277                        reply.error(libc::EIO);
2278                        return;
2279                    }
2280
2281                    // Return updated attrs.
2282                    let mut fs = self.fs.borrow_mut();
2283                    match fs.metadata(fs_ino) {
2284                        Ok(meta) => {
2285                            let mut attr = fs_to_attr(ino, &meta);
2286                            attr.size = new_size;
2287                            attr.blocks = new_size.div_ceil(512);
2288                            reply.attr(&TTL, &attr);
2289                        }
2290                        Err(_) => reply.error(libc::EIO),
2291                    }
2292                    return;
2293                }
2294
2295                // No size change — just return current attrs.
2296                if rw_id >= 9_000_000 {
2297                    let counter = rw_id - 9_000_000;
2298                    let created_id = Self::created_overlay_id(counter);
2299                    let session = self.session.borrow();
2300                    if let Some(s) = session.as_ref() {
2301                        if let Some(entry) = s.overlay.created.get(&created_id) {
2302                            let attr = Self::overlay_created_attr(ino, entry.size, false);
2303                            reply.attr(&TTL, &attr);
2304                            return;
2305                        }
2306                        if let Some(entry) = s.overlay.dirs.get(&created_id) {
2307                            let attr = Self::overlay_created_attr(ino, entry.size, true);
2308                            reply.attr(&TTL, &attr);
2309                            return;
2310                        }
2311                    }
2312                    reply.error(libc::ENOENT);
2313                } else {
2314                    let fs_ino = rw_id;
2315                    let mut fs = self.fs.borrow_mut();
2316                    match fs.metadata(fs_ino) {
2317                        Ok(meta) => {
2318                            let mut attr = fs_to_attr(ino, &meta);
2319                            let session = self.session.borrow();
2320                            if let Some(s) = session.as_ref() {
2321                                let overlay_id = Self::modified_overlay_id(fs_ino);
2322                                if s.overlay.modified.contains_key(&fs_ino) {
2323                                    if let Ok(data) = s.read_overlay_file(&overlay_id) {
2324                                        attr.size = data.len() as u64;
2325                                        attr.blocks = attr.size.div_ceil(512);
2326                                    }
2327                                }
2328                            }
2329                            reply.attr(&TTL, &attr);
2330                        }
2331                        Err(_) => reply.error(libc::EIO),
2332                    }
2333                }
2334            }
2335            // For non-rw inodes, setattr is not supported.
2336            _ => reply.error(libc::EROFS),
2337        }
2338    }
2339}
2340
2341#[cfg(test)]
2342mod tests {
2343    use super::*;
2344    use crate::{FsDeletedInode, FsDirEntry, FsError, FsRecoveryResult, FsResult, FsTimelineEvent};
2345    use fuser::FileType;
2346    use std::time::{Duration, UNIX_EPOCH};
2347
2348    // -----------------------------------------------------------------------
2349    // virtual_dir_attr
2350    // -----------------------------------------------------------------------
2351
2352    #[test]
2353    fn virtual_dir_attr_is_directory() {
2354        let attr = virtual_dir_attr(1);
2355        assert_eq!(attr.ino, 1);
2356        assert_eq!(attr.kind, FileType::Directory);
2357        assert_eq!(attr.perm, 0o555);
2358        assert_eq!(attr.nlink, 2);
2359        assert_eq!(attr.size, 0);
2360        assert_eq!(attr.blocks, 0);
2361        assert_eq!(attr.uid, 0);
2362        assert_eq!(attr.gid, 0);
2363        assert_eq!(attr.blksize, 4096);
2364        assert_eq!(attr.atime, UNIX_EPOCH);
2365        assert_eq!(attr.mtime, UNIX_EPOCH);
2366        assert_eq!(attr.ctime, UNIX_EPOCH);
2367        assert_eq!(attr.crtime, UNIX_EPOCH);
2368    }
2369
2370    #[test]
2371    fn virtual_dir_attr_preserves_ino() {
2372        for ino in [1, 42, FUSE_ROOT_INO, FUSE_ORPHANS_INO, 999_999] {
2373            assert_eq!(virtual_dir_attr(ino).ino, ino);
2374        }
2375    }
2376
2377    // -----------------------------------------------------------------------
2378    // virtual_file_attr
2379    // -----------------------------------------------------------------------
2380
2381    #[test]
2382    fn virtual_file_attr_regular() {
2383        let attr = virtual_file_attr(100, 4096);
2384        assert_eq!(attr.ino, 100);
2385        assert_eq!(attr.kind, FileType::RegularFile);
2386        assert_eq!(attr.perm, 0o444);
2387        assert_eq!(attr.nlink, 1);
2388        assert_eq!(attr.size, 4096);
2389        assert_eq!(attr.blocks, 8); // 4096 / 512
2390    }
2391
2392    #[test]
2393    fn virtual_file_attr_zero_size() {
2394        let attr = virtual_file_attr(1, 0);
2395        assert_eq!(attr.size, 0);
2396        assert_eq!(attr.blocks, 0);
2397    }
2398
2399    #[test]
2400    fn virtual_file_attr_non_512_aligned() {
2401        // 1000 bytes -> ceil(1000/512) = 2 blocks
2402        let attr = virtual_file_attr(1, 1000);
2403        assert_eq!(attr.blocks, 2);
2404    }
2405
2406    // -----------------------------------------------------------------------
2407    // ts_to_systime
2408    // -----------------------------------------------------------------------
2409
2410    #[test]
2411    fn timestamp_conversion_positive() {
2412        let ts = FsTimestamp {
2413            seconds: 1_700_000_000,
2414            nanoseconds: 500_000_000,
2415        };
2416        let st = ts_to_systime(&ts);
2417        let dur = st.duration_since(UNIX_EPOCH).unwrap();
2418        assert_eq!(dur.as_secs(), 1_700_000_000);
2419        assert_eq!(dur.subsec_nanos(), 500_000_000);
2420    }
2421
2422    #[test]
2423    fn timestamp_zero() {
2424        let ts = FsTimestamp {
2425            seconds: 0,
2426            nanoseconds: 0,
2427        };
2428        let st = ts_to_systime(&ts);
2429        assert_eq!(st, UNIX_EPOCH);
2430    }
2431
2432    #[test]
2433    fn timestamp_negative_clamps_to_epoch() {
2434        let ts = FsTimestamp {
2435            seconds: -1,
2436            nanoseconds: 0,
2437        };
2438        let st = ts_to_systime(&ts);
2439        assert_eq!(st, UNIX_EPOCH);
2440    }
2441
2442    #[test]
2443    fn timestamp_negative_large_clamps_to_epoch() {
2444        let ts = FsTimestamp {
2445            seconds: -1_000_000,
2446            nanoseconds: 999_999_999,
2447        };
2448        let st = ts_to_systime(&ts);
2449        assert_eq!(st, UNIX_EPOCH);
2450    }
2451
2452    #[test]
2453    fn timestamp_epoch_plus_one_second() {
2454        let ts = FsTimestamp {
2455            seconds: 1,
2456            nanoseconds: 0,
2457        };
2458        let st = ts_to_systime(&ts);
2459        assert_eq!(st, UNIX_EPOCH + Duration::from_secs(1));
2460    }
2461
2462    // -----------------------------------------------------------------------
2463    // fs_file_type_to_fuse
2464    // -----------------------------------------------------------------------
2465
2466    #[test]
2467    fn file_type_mapping_regular() {
2468        assert_eq!(
2469            fs_file_type_to_fuse(FsFileType::RegularFile),
2470            FileType::RegularFile
2471        );
2472    }
2473
2474    #[test]
2475    fn file_type_mapping_directory() {
2476        assert_eq!(
2477            fs_file_type_to_fuse(FsFileType::Directory),
2478            FileType::Directory
2479        );
2480    }
2481
2482    #[test]
2483    fn file_type_mapping_symlink() {
2484        assert_eq!(fs_file_type_to_fuse(FsFileType::Symlink), FileType::Symlink);
2485    }
2486
2487    #[test]
2488    fn file_type_mapping_chardev() {
2489        assert_eq!(
2490            fs_file_type_to_fuse(FsFileType::CharDevice),
2491            FileType::CharDevice
2492        );
2493    }
2494
2495    #[test]
2496    fn file_type_mapping_blockdev() {
2497        assert_eq!(
2498            fs_file_type_to_fuse(FsFileType::BlockDevice),
2499            FileType::BlockDevice
2500        );
2501    }
2502
2503    #[test]
2504    fn file_type_mapping_fifo() {
2505        assert_eq!(fs_file_type_to_fuse(FsFileType::Fifo), FileType::NamedPipe);
2506    }
2507
2508    #[test]
2509    fn file_type_mapping_socket() {
2510        assert_eq!(fs_file_type_to_fuse(FsFileType::Socket), FileType::Socket);
2511    }
2512
2513    #[test]
2514    fn file_type_mapping_unknown() {
2515        assert_eq!(
2516            fs_file_type_to_fuse(FsFileType::Unknown),
2517            FileType::RegularFile
2518        );
2519    }
2520
2521    // -----------------------------------------------------------------------
2522    // fs_to_attr
2523    // -----------------------------------------------------------------------
2524
2525    #[test]
2526    fn fs_to_attr_regular_file() {
2527        let meta = FsMetadata {
2528            ino: 42,
2529            file_type: FsFileType::RegularFile,
2530            mode: 0o100_644,
2531            uid: 1000,
2532            gid: 1000,
2533            size: 100,
2534            links_count: 1,
2535            atime: FsTimestamp {
2536                seconds: 1_700_000_000,
2537                nanoseconds: 0,
2538            },
2539            mtime: FsTimestamp {
2540                seconds: 1_700_000_000,
2541                nanoseconds: 0,
2542            },
2543            ctime: FsTimestamp {
2544                seconds: 1_700_000_000,
2545                nanoseconds: 0,
2546            },
2547            crtime: FsTimestamp {
2548                seconds: 1_700_000_000,
2549                nanoseconds: 0,
2550            },
2551            allocated: true,
2552        };
2553        let attr = fs_to_attr(1012, &meta);
2554        assert_eq!(attr.ino, 1012);
2555        assert_eq!(attr.size, 100);
2556        assert_eq!(attr.kind, FileType::RegularFile);
2557        assert_eq!(attr.nlink, 1);
2558        assert_eq!(attr.perm, 0o644);
2559        assert_eq!(attr.uid, 1000);
2560        assert_eq!(attr.gid, 1000);
2561    }
2562
2563    #[test]
2564    fn fs_to_attr_directory() {
2565        let meta = FsMetadata {
2566            ino: 2,
2567            file_type: FsFileType::Directory,
2568            mode: 0o40755,
2569            uid: 0,
2570            gid: 0,
2571            size: 4096,
2572            links_count: 3,
2573            atime: FsTimestamp::default(),
2574            mtime: FsTimestamp::default(),
2575            ctime: FsTimestamp::default(),
2576            crtime: FsTimestamp::default(),
2577            allocated: true,
2578        };
2579        let attr = fs_to_attr(2000, &meta);
2580        assert_eq!(attr.kind, FileType::Directory);
2581        assert_eq!(attr.nlink, 3);
2582        assert_eq!(attr.perm, 0o755);
2583    }
2584
2585    #[test]
2586    fn fs_to_attr_symlink() {
2587        let meta = FsMetadata {
2588            ino: 10,
2589            file_type: FsFileType::Symlink,
2590            mode: 0o120_777,
2591            uid: 0,
2592            gid: 0,
2593            size: 11,
2594            links_count: 1,
2595            atime: FsTimestamp::default(),
2596            mtime: FsTimestamp::default(),
2597            ctime: FsTimestamp::default(),
2598            crtime: FsTimestamp::default(),
2599            allocated: true,
2600        };
2601        let attr = fs_to_attr(3000, &meta);
2602        assert_eq!(attr.kind, FileType::Symlink);
2603        assert_eq!(attr.perm, 0o777);
2604    }
2605
2606    #[test]
2607    fn fs_to_attr_blocks_calculation() {
2608        let meta = FsMetadata {
2609            ino: 42,
2610            file_type: FsFileType::RegularFile,
2611            mode: 0o100_644,
2612            uid: 0,
2613            gid: 0,
2614            size: 1000,
2615            links_count: 1,
2616            atime: FsTimestamp::default(),
2617            mtime: FsTimestamp::default(),
2618            ctime: FsTimestamp::default(),
2619            crtime: FsTimestamp::default(),
2620            allocated: true,
2621        };
2622        let attr = fs_to_attr(42, &meta);
2623        assert_eq!(attr.size, 1000);
2624        assert_eq!(attr.blocks, 2);
2625    }
2626
2627    #[test]
2628    fn fs_to_attr_blksize_always_4096() {
2629        let meta = FsMetadata {
2630            ino: 1,
2631            file_type: FsFileType::RegularFile,
2632            mode: 0o100_644,
2633            uid: 0,
2634            gid: 0,
2635            size: 0,
2636            links_count: 1,
2637            atime: FsTimestamp::default(),
2638            mtime: FsTimestamp::default(),
2639            ctime: FsTimestamp::default(),
2640            crtime: FsTimestamp::default(),
2641            allocated: true,
2642        };
2643        let attr = fs_to_attr(1, &meta);
2644        assert_eq!(attr.blksize, 4096);
2645    }
2646
2647    // -----------------------------------------------------------------------
2648    // ForensicFuseFs::overlay_created_attr
2649    // -----------------------------------------------------------------------
2650
2651    #[test]
2652    fn overlay_created_attr_regular_file() {
2653        let attr = ForensicFuseFs::overlay_created_attr(999, 512, false);
2654        assert_eq!(attr.ino, 999);
2655        assert_eq!(attr.size, 512);
2656        assert_eq!(attr.kind, FileType::RegularFile);
2657        assert_eq!(attr.perm, 0o644);
2658        assert_eq!(attr.nlink, 1);
2659        assert_eq!(attr.blocks, 1);
2660    }
2661
2662    #[test]
2663    fn overlay_created_attr_directory() {
2664        let attr = ForensicFuseFs::overlay_created_attr(888, 0, true);
2665        assert_eq!(attr.kind, FileType::Directory);
2666        assert_eq!(attr.perm, 0o755);
2667    }
2668
2669    // -----------------------------------------------------------------------
2670    // ForensicFuseFs helper methods (static/associated)
2671    // -----------------------------------------------------------------------
2672
2673    #[test]
2674    fn modified_overlay_id_format() {
2675        assert_eq!(ForensicFuseFs::modified_overlay_id(42), "ino_42");
2676        assert_eq!(ForensicFuseFs::modified_overlay_id(0), "ino_0");
2677        assert_eq!(
2678            ForensicFuseFs::modified_overlay_id(9_999_999),
2679            "ino_9999999"
2680        );
2681    }
2682
2683    #[test]
2684    fn created_overlay_id_format() {
2685        assert_eq!(ForensicFuseFs::created_overlay_id(1), "new_1");
2686        assert_eq!(ForensicFuseFs::created_overlay_id(0), "new_0");
2687    }
2688
2689    // -----------------------------------------------------------------------
2690    // root_children — MountLayout decision (Humble Object)
2691    // -----------------------------------------------------------------------
2692
2693    fn root_child_names(layout: crate::MountLayout) -> Vec<String> {
2694        let mut fs = MockForensicFs;
2695        let root = fs.root_ino();
2696        root_children(layout, &mut fs, root, false)
2697            .unwrap()
2698            .iter()
2699            .map(|(_, n, _)| String::from_utf8_lossy(n).to_string())
2700            .collect()
2701    }
2702
2703    #[test]
2704    fn root_children_raw_lists_fs_tree_without_overlay() {
2705        let names = root_child_names(crate::MountLayout::Raw);
2706        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
2707        assert!(names.contains(&"subdir".to_string()), "got {names:?}");
2708        assert!(
2709            !names
2710                .iter()
2711                .any(|n| n == "rw" || n == "deleted" || n == "ro"),
2712            "Raw root must have no overlay dirs: {names:?}"
2713        );
2714    }
2715
2716    #[test]
2717    fn root_children_diskoverlay_lists_virtual_dirs() {
2718        let names = root_child_names(crate::MountLayout::DiskOverlay);
2719        for d in ["ro", "rw", "journal", "metadata", "unallocated", "session"] {
2720            assert!(names.contains(&d.to_string()), "missing {d}: {names:?}");
2721        }
2722        // The flat deleted/ directory is gone in v2 (in-place rendering).
2723        assert!(!names.contains(&"deleted".to_string()), "got {names:?}");
2724    }
2725
2726    #[test]
2727    fn root_children_diskoverlay_appends_orphans_when_present() {
2728        let mut fs = MockForensicFs;
2729        let root = fs.root_ino();
2730        let with: Vec<String> = root_children(crate::MountLayout::DiskOverlay, &mut fs, root, true)
2731            .unwrap()
2732            .iter()
2733            .map(|(_, n, _)| String::from_utf8_lossy(n).to_string())
2734            .collect();
2735        assert!(with.contains(&"$Orphans".to_string()), "got {with:?}");
2736    }
2737
2738    // -----------------------------------------------------------------------
2739    // VIRTUAL_DIRS constant
2740    // -----------------------------------------------------------------------
2741
2742    #[test]
2743    fn virtual_dirs_has_expected_entries() {
2744        // v2: the flat deleted/ dir is gone; six fixed virtual dirs remain.
2745        assert_eq!(VIRTUAL_DIRS.len(), 6);
2746        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "ro"));
2747        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "rw"));
2748        assert!(!VIRTUAL_DIRS.iter().any(|(_, name)| *name == "deleted"));
2749        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "journal"));
2750        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "metadata"));
2751        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "unallocated"));
2752        assert!(VIRTUAL_DIRS.iter().any(|(_, name)| *name == "session"));
2753    }
2754
2755    #[test]
2756    fn virtual_dirs_ino_matches_constants() {
2757        for &(ino, name) in VIRTUAL_DIRS {
2758            match name {
2759                "ro" => assert_eq!(ino, FUSE_RO_INO),
2760                "rw" => assert_eq!(ino, FUSE_RW_INO),
2761                "journal" => assert_eq!(ino, FUSE_JOURNAL_INO),
2762                "metadata" => assert_eq!(ino, FUSE_METADATA_INO),
2763                "unallocated" => assert_eq!(ino, FUSE_UNALLOCATED_INO),
2764                "session" => assert_eq!(ino, FUSE_SESSION_INO),
2765                _ => panic!("unexpected virtual dir: {name}"),
2766            }
2767        }
2768    }
2769
2770    // -----------------------------------------------------------------------
2771    // MockForensicFs + FUSE dispatch tests
2772    // -----------------------------------------------------------------------
2773
2774    struct MockForensicFs;
2775
2776    impl crate::ForensicFs for MockForensicFs {
2777        fn root_ino(&self) -> u64 {
2778            2
2779        }
2780
2781        fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
2782            match ino {
2783                2 => Ok(vec![
2784                    FsDirEntry {
2785                        inode: 2,
2786                        name: b".".to_vec(),
2787                        file_type: FsFileType::Directory,
2788                    },
2789                    FsDirEntry {
2790                        inode: 2,
2791                        name: b"..".to_vec(),
2792                        file_type: FsFileType::Directory,
2793                    },
2794                    FsDirEntry {
2795                        inode: 10,
2796                        name: b"hello.txt".to_vec(),
2797                        file_type: FsFileType::RegularFile,
2798                    },
2799                    FsDirEntry {
2800                        inode: 11,
2801                        name: b"subdir".to_vec(),
2802                        file_type: FsFileType::Directory,
2803                    },
2804                ]),
2805                11 => Ok(vec![
2806                    FsDirEntry {
2807                        inode: 11,
2808                        name: b".".to_vec(),
2809                        file_type: FsFileType::Directory,
2810                    },
2811                    FsDirEntry {
2812                        inode: 2,
2813                        name: b"..".to_vec(),
2814                        file_type: FsFileType::Directory,
2815                    },
2816                    FsDirEntry {
2817                        inode: 12,
2818                        name: b"nested.txt".to_vec(),
2819                        file_type: FsFileType::RegularFile,
2820                    },
2821                ]),
2822                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2823            }
2824        }
2825
2826        fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
2827            let entries = self.read_dir(parent_ino)?;
2828            Ok(entries.iter().find(|e| e.name == name).map(|e| e.inode))
2829        }
2830
2831        fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
2832            let (file_type, size) = match ino {
2833                2 | 11 => (FsFileType::Directory, 4096),
2834                10 => (FsFileType::RegularFile, 12),
2835                12 => (FsFileType::RegularFile, 11),
2836                _ => return Err(FsError::NotFound(format!("inode {ino}"))),
2837            };
2838            Ok(FsMetadata {
2839                ino,
2840                file_type,
2841                mode: if file_type == FsFileType::Directory {
2842                    0o40755
2843                } else {
2844                    0o100_644
2845                },
2846                uid: 1000,
2847                gid: 1000,
2848                size: size as u64,
2849                links_count: if file_type == FsFileType::Directory {
2850                    2
2851                } else {
2852                    1
2853                },
2854                atime: FsTimestamp {
2855                    seconds: 1_700_000_000,
2856                    nanoseconds: 0,
2857                },
2858                mtime: FsTimestamp {
2859                    seconds: 1_700_000_000,
2860                    nanoseconds: 0,
2861                },
2862                ctime: FsTimestamp {
2863                    seconds: 1_700_000_000,
2864                    nanoseconds: 0,
2865                },
2866                crtime: FsTimestamp {
2867                    seconds: 1_699_000_000,
2868                    nanoseconds: 0,
2869                },
2870                allocated: true,
2871            })
2872        }
2873
2874        fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
2875            match ino {
2876                10 => Ok(b"Hello, mock!".to_vec()),
2877                12 => Ok(b"Nested file".to_vec()),
2878                // Deleted nodes whose content is recoverable.
2879                100..=103 => Ok(vec![0xAB; 100]),
2880                // 104 (gone.txt) deliberately unreadable — falls through to Err.
2881                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2882            }
2883        }
2884
2885        fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
2886            let data = self.read_file(ino)?;
2887            let start = (offset as usize).min(data.len());
2888            let end = (start + len as usize).min(data.len());
2889            Ok(data[start..end].to_vec())
2890        }
2891
2892        fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
2893            Err(FsError::NotFound("no symlinks in mock".to_string()))
2894        }
2895
2896        fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
2897            Ok(vec![FsDeletedInode {
2898                ino: 99,
2899                file_type: FsFileType::RegularFile,
2900                size: 100,
2901                dtime: 1_700_001_000,
2902                recoverability: 0.75,
2903            }])
2904        }
2905
2906        fn deleted_nodes(&mut self) -> FsResult<Vec<crate::FsDeletedNode>> {
2907            let mk = |ino: u64,
2908                      name: &[u8],
2909                      parent: Option<u64>,
2910                      mtime: i64,
2911                      record_id: u64,
2912                      allocation: crate::FsAllocation| {
2913                crate::FsDeletedNode {
2914                    ino,
2915                    name: name.to_vec(),
2916                    parent_ino: parent,
2917                    size: 100,
2918                    file_type: FsFileType::RegularFile,
2919                    allocation,
2920                    record_id,
2921                    atime: FsTimestamp::default(),
2922                    mtime: FsTimestamp {
2923                        seconds: mtime,
2924                        nanoseconds: 0,
2925                    },
2926                    ctime: FsTimestamp::default(),
2927                    crtime: FsTimestamp::default(),
2928                }
2929            };
2930            Ok(vec![
2931                // Two same-name deletes under the live root (ino 2): newest wins
2932                // the in-place slot, the older is a same-name orphan.
2933                mk(
2934                    100,
2935                    b"report.txt",
2936                    Some(2),
2937                    200,
2938                    100,
2939                    crate::FsAllocation::Deleted,
2940                ),
2941                mk(
2942                    101,
2943                    b"report.txt",
2944                    Some(2),
2945                    100,
2946                    101,
2947                    crate::FsAllocation::Deleted,
2948                ),
2949                // Collides with the live hello.txt (ino 10) -> $Orphans.
2950                mk(
2951                    102,
2952                    b"hello.txt",
2953                    Some(2),
2954                    300,
2955                    102,
2956                    crate::FsAllocation::Deleted,
2957                ),
2958                // Nameless true orphan (no parent) -> $Orphans.
2959                mk(103, b"", None, 150, 103, crate::FsAllocation::Orphan),
2960                // In-place candidate whose content is unreadable (ino 104 errors
2961                // in read_file) -> honest unreadable marker, never a 0-byte fake.
2962                mk(
2963                    104,
2964                    b"gone.txt",
2965                    Some(2),
2966                    250,
2967                    104,
2968                    crate::FsAllocation::Deleted,
2969                ),
2970            ])
2971        }
2972
2973        fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
2974            match ino {
2975                99 => Ok(FsRecoveryResult {
2976                    ino: 99,
2977                    data: vec![0xDE; 100],
2978                    expected_size: 100,
2979                    recovered_bytes: 100,
2980                    recovery_percentage: 1.0,
2981                }),
2982                _ => Err(FsError::NotFound(format!("inode {ino}"))),
2983            }
2984        }
2985
2986        fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
2987            Ok(vec![FsTimelineEvent {
2988                timestamp: FsTimestamp {
2989                    seconds: 1_700_000_000,
2990                    nanoseconds: 0,
2991                },
2992                event_type: FsEventType::Created,
2993                inode: 10,
2994                size: 12,
2995                uid: 1000,
2996                gid: 1000,
2997            }])
2998        }
2999
3000        fn fs_info(&self) -> FsResult<serde_json::Value> {
3001            Ok(serde_json::json!({ "filesystem": "mock", "block_size": 4096 }))
3002        }
3003
3004        fn block_size(&self) -> u64 {
3005            4096
3006        }
3007    }
3008
3009    fn make_mock_fuse() -> ForensicFuseFs {
3010        ForensicFuseFs::new(
3011            Box::new(MockForensicFs),
3012            None,
3013            crate::MountLayout::DiskOverlay,
3014            crate::DeletedMode::Latest,
3015        )
3016    }
3017
3018    #[test]
3019    fn mock_ensure_deleted_cache() {
3020        let fuse = make_mock_fuse();
3021        assert!(fuse.deleted_cache.borrow().is_none());
3022        fuse.ensure_deleted_cache();
3023        let cache = fuse.deleted_cache.borrow();
3024        let entries = cache.as_ref().expect("cache should be populated");
3025        // Five recovered nodes from the mock, real names — never fabricated.
3026        assert_eq!(entries.len(), 5);
3027        assert!(entries
3028            .iter()
3029            .any(|e| e.fs_ino == 100 && e.name == "report.txt"));
3030    }
3031
3032    #[test]
3033    fn mock_ensure_metadata_cache() {
3034        let fuse = make_mock_fuse();
3035        assert!(fuse.metadata_cache.borrow().is_none());
3036        fuse.ensure_metadata_cache();
3037        let cache = fuse.metadata_cache.borrow();
3038        let mc = cache.as_ref().expect("cache should be populated");
3039        let sb_str = String::from_utf8_lossy(&mc.superblock_json);
3040        assert!(
3041            sb_str.contains("mock"),
3042            "superblock_json should contain 'mock': {sb_str}"
3043        );
3044        assert!(
3045            !mc.timeline_jsonl.is_empty(),
3046            "timeline_jsonl should not be empty"
3047        );
3048    }
3049
3050    #[test]
3051    fn mock_root_ino_stored() {
3052        let fuse = make_mock_fuse();
3053        assert_eq!(fuse.root_ino, 2);
3054    }
3055
3056    #[test]
3057    fn mock_has_session_false() {
3058        let fuse = make_mock_fuse();
3059        assert!(!fuse.has_session());
3060    }
3061
3062    #[test]
3063    fn mock_read_file_through_fs() {
3064        let fuse = make_mock_fuse();
3065        let mut fs = fuse.fs.borrow_mut();
3066        let data = fs.read_file(10).expect("read_file(10) should succeed");
3067        assert_eq!(data, b"Hello, mock!");
3068    }
3069
3070    #[test]
3071    fn mock_read_file_range_through_fs() {
3072        let fuse = make_mock_fuse();
3073        let mut fs = fuse.fs.borrow_mut();
3074        let data = fs
3075            .read_file_range(10, 0, 5)
3076            .expect("read_file_range should succeed");
3077        assert_eq!(data, b"Hello");
3078    }
3079
3080    #[test]
3081    fn mock_lookup_through_fs() {
3082        let fuse = make_mock_fuse();
3083        let mut fs = fuse.fs.borrow_mut();
3084        let result = fs.lookup(2, b"hello.txt").expect("lookup should succeed");
3085        assert_eq!(result, Some(10));
3086    }
3087
3088    #[test]
3089    fn mock_metadata_through_fs() {
3090        let fuse = make_mock_fuse();
3091        let mut fs = fuse.fs.borrow_mut();
3092        let meta = fs.metadata(10).expect("metadata(10) should succeed");
3093        assert_eq!(meta.file_type, FsFileType::RegularFile);
3094        assert_eq!(meta.size, 12);
3095        assert_eq!(meta.ino, 10);
3096    }
3097
3098    #[test]
3099    fn mock_fs_to_attr() {
3100        let fuse = make_mock_fuse();
3101        let meta = {
3102            let mut fs = fuse.fs.borrow_mut();
3103            fs.metadata(10).expect("metadata(10) should succeed")
3104        };
3105        let attr = fs_to_attr(ro_ino(10), &meta);
3106        assert_eq!(attr.ino, ro_ino(10));
3107        assert_eq!(attr.kind, FileType::RegularFile);
3108        assert_eq!(attr.size, 12);
3109        assert_eq!(attr.perm, 0o644);
3110        assert_eq!(attr.uid, 1000);
3111        assert_eq!(attr.gid, 1000);
3112        assert_eq!(attr.nlink, 1);
3113        assert_eq!(attr.blksize, 4096);
3114        let expected_atime = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
3115        assert_eq!(attr.atime, expected_atime);
3116        let expected_crtime = UNIX_EPOCH + Duration::from_secs(1_699_000_000);
3117        assert_eq!(attr.crtime, expected_crtime);
3118    }
3119
3120    #[test]
3121    fn mock_timeline_through_fs() {
3122        let fuse = make_mock_fuse();
3123        let mut fs = fuse.fs.borrow_mut();
3124        let events = fs.timeline().expect("timeline should succeed");
3125        assert_eq!(events.len(), 1);
3126        assert_eq!(events[0].event_type, FsEventType::Created);
3127        assert_eq!(events[0].inode, 10);
3128        assert_eq!(events[0].size, 12);
3129    }
3130
3131    #[test]
3132    fn mock_ensure_journal_cache_empty() {
3133        let fuse = make_mock_fuse();
3134        assert!(fuse.journal_cache.borrow().is_none());
3135        fuse.ensure_journal_cache();
3136        let cache = fuse.journal_cache.borrow();
3137        let entries = cache.as_ref().expect("cache should be populated");
3138        assert!(
3139            entries.is_empty(),
3140            "mock has no journal_transactions override, should be empty"
3141        );
3142    }
3143
3144    // -----------------------------------------------------------------------
3145    // Deleted-node placement (Task 2): real names, in-place vs $Orphans, gating
3146    // -----------------------------------------------------------------------
3147
3148    fn make_mock_fuse_mode(mode: crate::DeletedMode) -> ForensicFuseFs {
3149        ForensicFuseFs::new(
3150            Box::new(MockForensicFs),
3151            None,
3152            crate::MountLayout::DiskOverlay,
3153            mode,
3154        )
3155    }
3156
3157    #[test]
3158    fn filename_safe_utc_has_no_colons() {
3159        // 1_700_000_000 == 2023-11-14T22:13:20Z -> colons become hyphens.
3160        assert_eq!(filename_safe_utc(1_700_000_000), "2023-11-14T22-13-20Z");
3161    }
3162
3163    #[test]
3164    fn deleted_cache_latest_places_newest_in_place() {
3165        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3166        fuse.ensure_deleted_cache();
3167        let cache = fuse.deleted_cache.borrow();
3168        let entries = cache.as_ref().expect("cache populated");
3169        let by = |ino: u64| {
3170            entries
3171                .iter()
3172                .find(|e| e.fs_ino == ino)
3173                .unwrap_or_else(|| panic!("no cache entry for ino {ino}"))
3174        };
3175        // Newest report.txt renders in-place under its real name.
3176        let a = by(100);
3177        assert_eq!(a.name, "report.txt");
3178        assert!(!a.orphan);
3179        // Older same-name delete -> $Orphans, disambiguated `<name>@<ts>Z~<id>`.
3180        let b = by(101);
3181        assert!(b.orphan);
3182        assert!(b.name.starts_with("report.txt@"), "got {}", b.name);
3183        assert!(b.name.ends_with("~101"), "got {}", b.name);
3184        // Live-name collision -> $Orphans.
3185        assert!(by(102).orphan);
3186        // Nameless true orphan -> $Orphans.
3187        assert!(by(103).orphan);
3188        // In-place but unreadable content -> honest marker, not a 0-byte fake.
3189        let g = by(104);
3190        assert_eq!(g.name, "gone.txt");
3191        assert!(!g.orphan);
3192        assert!(!g.readable);
3193    }
3194
3195    #[test]
3196    fn deleted_cache_off_is_empty() {
3197        let fuse = make_mock_fuse_mode(crate::DeletedMode::Off);
3198        fuse.ensure_deleted_cache();
3199        assert!(fuse
3200            .deleted_cache
3201            .borrow()
3202            .as_ref()
3203            .expect("cache populated")
3204            .is_empty());
3205    }
3206
3207    #[test]
3208    fn deleted_cache_all_routes_everything_to_orphans() {
3209        let fuse = make_mock_fuse_mode(crate::DeletedMode::All);
3210        fuse.ensure_deleted_cache();
3211        let cache = fuse.deleted_cache.borrow();
3212        let entries = cache.as_ref().expect("cache populated");
3213        assert_eq!(entries.len(), 5);
3214        assert!(
3215            entries.iter().all(|e| e.orphan),
3216            "All -> every instance orphan"
3217        );
3218        let a = entries.iter().find(|e| e.fs_ino == 100).unwrap();
3219        assert!(a.name.starts_with("report.txt@"), "got {}", a.name);
3220    }
3221
3222    #[test]
3223    fn deleted_cache_never_fabricates_unknown_names() {
3224        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3225        fuse.ensure_deleted_cache();
3226        let cache = fuse.deleted_cache.borrow();
3227        for e in cache.as_ref().expect("cache populated") {
3228            assert!(!e.name.ends_with("_unknown"), "fabricated: {}", e.name);
3229        }
3230    }
3231
3232    #[test]
3233    fn timeline_jsonl_carries_every_deleted_instance() {
3234        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3235        fuse.ensure_metadata_cache();
3236        let cache = fuse.metadata_cache.borrow();
3237        let mc = cache.as_ref().expect("metadata cache populated");
3238        let text = String::from_utf8_lossy(&mc.timeline_jsonl);
3239        // A deleted-instance row is any JSONL line carrying a `placement` field.
3240        let rows: Vec<serde_json::Value> = text
3241            .lines()
3242            .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
3243            .filter(|v| v.get("placement").is_some())
3244            .collect();
3245        // One row per deleted instance (all 5 mock nodes).
3246        assert_eq!(rows.len(), 5, "one row per deleted instance");
3247        // Every version of a same-named deleted file (grep by name).
3248        let report: Vec<_> = rows.iter().filter(|r| r["name"] == "report.txt").collect();
3249        assert_eq!(report.len(), 2, "both report.txt instances present");
3250        assert!(report.iter().any(|r| r["placement"] == "in-place"));
3251        assert!(report.iter().any(|r| r["placement"] == "orphan"));
3252        // Required fields present on a row.
3253        let r = &rows[0];
3254        for f in ["path", "name", "record_id", "allocation", "status", "macb"] {
3255            assert!(r.get(f).is_some(), "row missing {f}: {r}");
3256        }
3257        assert!(r["macb"].get("modified").is_some());
3258        // Unreadable content surfaced honestly in the status.
3259        assert!(rows
3260            .iter()
3261            .any(|r| r["name"] == "gone.txt" && r["status"] == "unreadable"));
3262    }
3263
3264    // -----------------------------------------------------------------------
3265    // ADR 0008 v2 (a): in-place recovered-deleted entries render in the main
3266    // navigable tree at their recovered parent, under their real name.
3267    // -----------------------------------------------------------------------
3268
3269    #[test]
3270    fn in_place_children_injected_under_parent() {
3271        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3272        fuse.ensure_deleted_cache();
3273        let cache = fuse.deleted_cache.borrow();
3274        let entries = cache.as_ref().expect("cache populated");
3275        let kids = deleted_in_place_children(entries, 2);
3276        // report.txt (ino 100, newest) and gone.txt (ino 104) are the in-place
3277        // deletes under the live root (parent ino 2); orphans are excluded.
3278        let names: Vec<&str> = kids.iter().map(|(_, n)| n.as_str()).collect();
3279        assert!(names.contains(&"report.txt"), "got {names:?}");
3280        assert!(names.contains(&"gone.txt"), "got {names:?}");
3281        assert_eq!(
3282            kids.len(),
3283            2,
3284            "only the two in-place deletes under parent 2"
3285        );
3286        // Real recovered name — no `(deleted)` decoration.
3287        assert!(
3288            !names.iter().any(|n| n.contains("(deleted)")),
3289            "got {names:?}"
3290        );
3291        // Child inode is the deleted-namespace encoding, so getattr/read resolve it.
3292        assert!(kids.iter().any(|(ino, _)| *ino == deleted_ino(100)));
3293        // A directory with no recovered deleted children gets nothing injected.
3294        assert!(deleted_in_place_children(entries, 11).is_empty());
3295        // Orphans never inject in-place (ino 101/102/103 are routed to $Orphans).
3296        assert!(!kids.iter().any(|(ino, _)| *ino == deleted_ino(101)));
3297    }
3298
3299    // -----------------------------------------------------------------------
3300    // ADR 0008 v2 (b): `$Orphans/` is a top-level synthetic directory (not a
3301    // `deleted/` subtree), shown only when unplaceable entries exist.
3302    // -----------------------------------------------------------------------
3303
3304    #[test]
3305    fn orphans_dir_is_top_level_only_when_present() {
3306        use crate::inode_map::FUSE_ORPHANS_INO;
3307        // With orphans present, `$Orphans` joins the root listing; the flat
3308        // `deleted/` directory is gone.
3309        let with = root_dir_listing(true);
3310        assert!(
3311            with.iter()
3312                .any(|&(ino, n)| ino == FUSE_ORPHANS_INO && n == "$Orphans"),
3313            "root should list $Orphans when orphans exist: {with:?}"
3314        );
3315        assert!(
3316            !with.iter().any(|&(_, n)| n == "deleted"),
3317            "the flat deleted/ dir is removed in v2: {with:?}"
3318        );
3319        // Stable virtual dirs stay.
3320        for want in ["ro", "rw", "metadata", "session"] {
3321            assert!(
3322                with.iter().any(|&(_, n)| n == want),
3323                "missing {want}: {with:?}"
3324            );
3325        }
3326        // No orphans -> no $Orphans at the root.
3327        let without = root_dir_listing(false);
3328        assert!(
3329            !without.iter().any(|&(_, n)| n == "$Orphans"),
3330            "no $Orphans without orphan entries: {without:?}"
3331        );
3332    }
3333
3334    #[test]
3335    fn cache_has_orphans_reflects_placement() {
3336        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3337        fuse.ensure_deleted_cache();
3338        let cache = fuse.deleted_cache.borrow();
3339        let entries = cache.as_ref().expect("cache populated");
3340        // Mock routes 101/102/103 to $Orphans.
3341        assert!(cache_has_orphans(entries));
3342        assert!(!cache_has_orphans(&[]));
3343    }
3344
3345    // -----------------------------------------------------------------------
3346    // ADR 0008 v2 (c): the deleted status + recovered MACB times ride an
3347    // out-of-band xattr channel (user.4n6.*), never a name/mode decoration.
3348    // -----------------------------------------------------------------------
3349
3350    #[test]
3351    fn deleted_xattr_names_are_the_marking_schema() {
3352        let names = deleted_xattr_names();
3353        for want in [
3354            "user.4n6.status",
3355            "user.4n6.macb.modified",
3356            "user.4n6.macb.accessed",
3357            "user.4n6.macb.changed",
3358            "user.4n6.macb.born",
3359        ] {
3360            assert!(names.contains(&want), "missing {want}: {names:?}");
3361        }
3362        assert_eq!(names.len(), 5);
3363    }
3364
3365    #[test]
3366    fn deleted_xattr_value_status_and_macb() {
3367        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3368        fuse.ensure_deleted_cache();
3369        let cache = fuse.deleted_cache.borrow();
3370        let entries = cache.as_ref().expect("cache populated");
3371        let by = |ino: u64| entries.iter().find(|e| e.fs_ino == ino).unwrap();
3372
3373        // In-place Deleted entry (ino 100) -> status "deleted".
3374        let a = by(100);
3375        assert_eq!(
3376            deleted_xattr_value(a, "user.4n6.status").as_deref(),
3377            Some(b"deleted".as_ref())
3378        );
3379        // Recovered mtime surfaces as ISO-8601 UTC (mock mtime seconds = 200).
3380        assert_eq!(
3381            deleted_xattr_value(a, "user.4n6.macb.modified").as_deref(),
3382            Some(b"1970-01-01T00:03:20Z".as_ref())
3383        );
3384        // True orphan (ino 103) -> status "orphan".
3385        assert_eq!(
3386            deleted_xattr_value(by(103), "user.4n6.status").as_deref(),
3387            Some(b"orphan".as_ref())
3388        );
3389        // Unknown attribute -> None (getxattr replies ENODATA).
3390        assert!(deleted_xattr_value(a, "user.4n6.nope").is_none());
3391    }
3392
3393    // -----------------------------------------------------------------------
3394    // ADR 0008 v2 (d): recovered-deleted entries are COW-writable like live
3395    // files — a write copies up the recovered bytes, leaving the base untouched.
3396    // -----------------------------------------------------------------------
3397
3398    #[test]
3399    fn deleted_cow_base_yields_recovered_bytes() {
3400        let fuse = make_mock_fuse_mode(crate::DeletedMode::Latest);
3401        fuse.ensure_deleted_cache();
3402        let cache = fuse.deleted_cache.borrow();
3403        let entries = cache.as_ref().expect("cache populated");
3404
3405        // A readable in-place delete (ino 100) copies up from its recovered
3406        // bytes — the write path is not forced read-only.
3407        let base = deleted_cow_base(entries, 100).expect("readable -> copy-up base");
3408        assert_eq!(base, vec![0xAB; 100]);
3409
3410        // An unreadable recovered entry (ino 104, gone.txt) has no base to copy
3411        // up — the write must fail loud, never fabricate an empty file.
3412        assert!(deleted_cow_base(entries, 104).is_none());
3413
3414        // Unknown inode -> no base.
3415        assert!(deleted_cow_base(entries, 999).is_none());
3416    }
3417}