Skip to main content

forensic_mount/
engine_fs.rs

1#![forbid(unsafe_code)]
2
3//! The disk-image [`ForensicFs`] backend: an adapter over the `forensic-vfs`
4//! engine's read-only [`FileSystem`] contract.
5//!
6//! The FUSE/Dokan mount layer speaks 4n6mount's own `u64`-inode
7//! [`ForensicFs`](crate::ForensicFs) vocabulary; the engine speaks
8//! `forensic_vfs::FileId` (a per-filesystem identity *enum*) and streams owned
9//! iterators. [`EngineFs`] bridges the two: it keeps a bidirectional
10//! `FileId <-> u64` map (a dense allocator, so the huge inode space collapses to
11//! small FUSE inodes) and converts [`FsMeta`](forensic_vfs::FsMeta) into the
12//! mount layer's [`FsMetadata`](crate::FsMetadata).
13//!
14//! Some forensic surfaces of the old backends have **no** equivalent on the
15//! engine's inode-addressed `FileSystem` trait — deleted-file *recovery*,
16//! event *timelines*, and journal *transactions*. Those degrade loud (an
17//! explicit `NotSupported` error, never a fabricated success); see each gate's
18//! `TODO(engine)`.
19
20use std::collections::HashMap;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use forensic_vfs::{
25    Allocation, DynFs, FileId, Layer, Locator, MacbTimes, NodeKind, StreamId, TimeStamp,
26    TimeZonePolicy, VfsError,
27};
28use forensic_vfs_engine::Vfs;
29
30use crate::{
31    not_supported, ForensicFs, FsAllocation, FsBlockRange, FsDeletedInode, FsDeletedNode,
32    FsDirEntry, FsError, FsFileType, FsMetadata, FsRecoveryResult, FsResult, FsTimelineEvent,
33    FsTimestamp, FsTransaction,
34};
35
36/// Cap on deleted/unallocated enumeration — a bomb guard against a hostile
37/// filesystem streaming an unbounded node/run list into a mount cache.
38const ENUM_CAP: usize = 100_000;
39
40/// A disk-image filesystem mounted through the engine.
41///
42/// `_tmp` keeps a peeled-and-spilled inner image (e.g. from `evidence.dd.gz`)
43/// alive for exactly the mount's lifetime: `fs` is declared first so its open
44/// file handle drops *before* the temp file is unlinked (correct on Windows).
45pub struct EngineFs {
46    fs: DynFs,
47    /// `FileId -> FUSE inode` and its inverse, plus the next dense id.
48    fwd: HashMap<FileId, u64>,
49    rev: HashMap<u64, FileId>,
50    next: u64,
51    root_u64: u64,
52    /// A peeled inner image spilled to a temp file, removed when this drops.
53    _tmp: Option<tempfile::TempPath>,
54}
55
56impl EngineFs {
57    /// Wrap a mounted engine filesystem. `tmp` is the temp file backing a peeled
58    /// image, if any — kept alive (and auto-removed) for the mount's lifetime.
59    fn new(fs: DynFs, tmp: Option<tempfile::TempPath>) -> Self {
60        let root = fs.root();
61        let mut this = Self {
62            fs,
63            fwd: HashMap::new(),
64            rev: HashMap::new(),
65            // Start above the reserved virtual inodes (1..=9 in `inode_map`); the
66            // encoded `ro_ino`/`rw_ino` then never collide with them.
67            next: 10,
68            root_u64: 0,
69            _tmp: tmp,
70        };
71        this.root_u64 = this.assign(root);
72        this
73    }
74
75    /// Map a `FileId` to a stable dense FUSE inode, allocating on first sight.
76    fn assign(&mut self, id: FileId) -> u64 {
77        if let Some(&ino) = self.fwd.get(&id) {
78            return ino;
79        }
80        let ino = self.next;
81        self.next += 1;
82        self.fwd.insert(id, ino);
83        self.rev.insert(ino, id);
84        ino
85    }
86
87    /// Resolve a FUSE inode back to its `FileId`, or a loud not-found.
88    fn file_id(&self, ino: u64) -> FsResult<FileId> {
89        self.rev
90            .get(&ino)
91            .copied()
92            .ok_or_else(|| FsError::NotFound(format!("unknown inode {ino}")))
93    }
94
95    /// The mounted filesystem's kind as a short lowercase tag (e.g. `"ntfs"`,
96    /// `"fat"`) — used to label a partition in a [`MultiPartitionFs`].
97    #[must_use]
98    pub fn fs_kind_str(&self) -> &'static str {
99        self.fs.kind().as_str()
100    }
101}
102
103/// Map a `forensic-vfs` error into the mount layer's error, preserving the text.
104fn vfs_err(e: VfsError) -> FsError {
105    FsError::Other(e.to_string())
106}
107
108/// Map the engine's node kind to the mount layer's file type.
109fn node_kind(k: NodeKind) -> FsFileType {
110    match k {
111        NodeKind::File => FsFileType::RegularFile,
112        NodeKind::Dir => FsFileType::Directory,
113        NodeKind::Symlink => FsFileType::Symlink,
114        NodeKind::Device => FsFileType::CharDevice,
115        // `NodeKind::Other` plus any future `#[non_exhaustive]` variant map to
116        // Unknown rather than fabricating a specific type.
117        _ => FsFileType::Unknown,
118    }
119}
120
121/// Convert an engine timestamp (nanoseconds since the Unix epoch) into the
122/// seconds/nanoseconds split the mount layer uses. `None` becomes the zero time.
123fn ts(t: Option<TimeStamp>) -> FsTimestamp {
124    match t {
125        Some(t) => FsTimestamp {
126            seconds: (t.unix_nanos.div_euclid(1_000_000_000)) as i64,
127            nanoseconds: (t.unix_nanos.rem_euclid(1_000_000_000)) as u32,
128        },
129        None => FsTimestamp::default(),
130    }
131}
132
133/// Assemble the mount layer's metadata from an engine `FsMeta`.
134fn to_metadata(ino: u64, meta: &forensic_vfs::FsMeta, times: &MacbTimes) -> FsMetadata {
135    let file_type = node_kind(meta.kind);
136    // The engine exposes a Unix mode only where the filesystem records one
137    // (ext/APFS); NTFS/FAT return `None`, so synthesize a sensible default that
138    // still carries the type bits `fs_to_attr` masks for `perm`.
139    let mode = meta.mode.map_or_else(
140        || match file_type {
141            FsFileType::Directory => 0o040_755,
142            FsFileType::Symlink => 0o120_777,
143            _ => 0o100_644,
144        },
145        |m| (m & 0xFFFF) as u16,
146    );
147    FsMetadata {
148        ino,
149        file_type,
150        mode,
151        uid: meta.uid.unwrap_or(0),
152        gid: meta.gid.unwrap_or(0),
153        size: meta.size,
154        links_count: meta.nlink.min(u32::from(u16::MAX)) as u16,
155        atime: ts(times.accessed),
156        mtime: ts(times.modified),
157        ctime: ts(times.changed),
158        crtime: ts(times.born),
159        allocated: matches!(meta.allocated, Allocation::Allocated),
160    }
161}
162
163impl ForensicFs for EngineFs {
164    fn root_ino(&self) -> u64 {
165        self.root_u64
166    }
167
168    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
169        let id = self.file_id(ino)?;
170        let stream = self.fs.read_dir(id).map_err(vfs_err)?;
171        let mut out = Vec::new();
172        for entry in stream {
173            let entry = entry.map_err(vfs_err)?;
174            let child = self.assign(entry.id);
175            out.push(FsDirEntry {
176                inode: child,
177                name: entry.name,
178                file_type: node_kind(entry.kind),
179            });
180        }
181        Ok(out)
182    }
183
184    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
185        let parent = self.file_id(parent_ino)?;
186        match self.fs.lookup(parent, name).map_err(vfs_err)? {
187            Some(id) => Ok(Some(self.assign(id))),
188            None => Ok(None),
189        }
190    }
191
192    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
193        let id = self.file_id(ino)?;
194        let meta = self.fs.meta(id).map_err(vfs_err)?;
195        Ok(to_metadata(ino, &meta, &meta.times))
196    }
197
198    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
199        let id = self.file_id(ino)?;
200        let size = self.fs.meta(id).map_err(vfs_err)?.size;
201        self.read_file_range(ino, 0, size)
202    }
203
204    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
205        let id = self.file_id(ino)?;
206        let mut buf = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
207        let mut filled = 0usize;
208        while filled < buf.len() {
209            let n = self
210                .fs
211                .read_at(
212                    id,
213                    StreamId::Default,
214                    offset + filled as u64,
215                    &mut buf[filled..],
216                )
217                .map_err(vfs_err)?;
218            if n == 0 {
219                break;
220            }
221            filled += n;
222        }
223        buf.truncate(filled);
224        Ok(buf)
225    }
226
227    fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
228        let id = self.file_id(ino)?;
229        self.fs.read_link(id, 4096).map_err(vfs_err)
230    }
231
232    fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
233        let stream = self.fs.deleted().map_err(vfs_err)?;
234        let mut out = Vec::new();
235        for meta in stream.take(ENUM_CAP) {
236            let meta = meta.map_err(vfs_err)?;
237            out.push(FsDeletedInode {
238                ino: meta.ino,
239                file_type: node_kind(meta.kind),
240                size: meta.size,
241                dtime: 0,
242                recoverability: 0.0,
243            });
244        }
245        Ok(out)
246    }
247
248    fn deleted_nodes(&mut self) -> FsResult<Vec<FsDeletedNode>> {
249        // The engine's rich deleted surface: each node carries a readable
250        // `FileId` (→ a dense FUSE inode here, usable with `read_file`), the
251        // recovered name, and the parent `FileId` (→ inode), so the mount can
252        // place it in-place or route it to `$Orphans`. Bomb-guarded by
253        // `ENUM_CAP`. The stream is owned, so allocating inodes as we go is safe.
254        let stream = self.fs.deleted_nodes().map_err(vfs_err)?;
255        let mut out = Vec::new();
256        for node in stream.take(ENUM_CAP) {
257            let node = node.map_err(vfs_err)?;
258            let ino = self.assign(node.id);
259            let parent_ino = node.parent.map(|p| self.assign(p));
260            let meta = &node.meta;
261            let allocation = match meta.allocated {
262                Allocation::Orphan => FsAllocation::Orphan,
263                // `Deleted` and any future/`Allocated` variant render as a
264                // deleted record (a recovered node is unlinked by definition).
265                _ => FsAllocation::Deleted,
266            };
267            out.push(FsDeletedNode {
268                ino,
269                name: node.name.clone(),
270                parent_ino,
271                size: meta.size,
272                file_type: node_kind(meta.kind),
273                allocation,
274                record_id: meta.ino,
275                atime: ts(meta.times.accessed),
276                mtime: ts(meta.times.modified),
277                ctime: ts(meta.times.changed),
278                crtime: ts(meta.times.born),
279            });
280        }
281        Ok(out)
282    }
283
284    fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
285        // TODO(engine): re-wire when FileSystem exposes recovery. `deleted()`
286        // yields metadata for deleted nodes but no `FileId` to read their bytes,
287        // so recovery has no home on the current trait — degrade loud.
288        let _ = ino;
289        Err(not_supported(
290            "recover_file (the forensic-vfs FileSystem trait has no deleted-content read path)",
291        ))
292    }
293
294    fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
295        // TODO(engine): re-wire when FileSystem exposes a timeline surface.
296        Err(not_supported(
297            "timeline (the forensic-vfs FileSystem trait has no event-timeline surface)",
298        ))
299    }
300
301    fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
302        let bs = self.block_size().max(1);
303        let stream = self.fs.unallocated().map_err(vfs_err)?;
304        let mut out = Vec::new();
305        for run in stream.take(ENUM_CAP) {
306            let run = run.map_err(vfs_err)?;
307            out.push(FsBlockRange {
308                start: run.run.image_offset,
309                // Report the length in blocks so the FUSE size (length * block
310                // size) reflects the real byte extent.
311                length: (run.run.len / bs).max(1),
312            });
313        }
314        Ok(out)
315    }
316
317    fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
318        // TODO(engine): re-wire when FileSystem (or the engine) exposes a raw
319        // image byte-reader. The inode-addressed trait cannot read an arbitrary
320        // image offset, so the unallocated *ranges* are listable but their bytes
321        // are not readable through it — degrade loud rather than fabricate.
322        Err(not_supported(
323            "read_unallocated (the forensic-vfs FileSystem trait has no raw-image byte reader)",
324        ))
325    }
326
327    fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
328        // TODO(engine): re-wire when FileSystem exposes journal transactions.
329        Err(not_supported(
330            "journal_transactions (the forensic-vfs FileSystem trait has no journal surface)",
331        ))
332    }
333
334    fn fs_info(&self) -> FsResult<serde_json::Value> {
335        let sizes = self.fs.sector_sizes();
336        let zone = match self.fs.timestamp_zone() {
337            TimeZonePolicy::Utc => "utc".to_string(),
338            TimeZonePolicy::LocalUnknown => "local-unknown".to_string(),
339            TimeZonePolicy::Local { minutes_east } => format!("local+{minutes_east}m"),
340            _ => "unknown".to_string(),
341        };
342        Ok(serde_json::json!({
343            "filesystem": self.fs.kind().as_str(),
344            "logical_sector_size": sizes.logical,
345            "physical_sector_size": sizes.physical,
346            "cluster_or_block_size": sizes.cluster_or_block,
347            "timestamp_zone": zone,
348        }))
349    }
350
351    fn block_size(&self) -> u64 {
352        let bs = self.fs.sector_sizes().cluster_or_block;
353        if bs == 0 {
354            4096
355        } else {
356            u64::from(bs)
357        }
358    }
359}
360
361/// Open a disk-image evidence file as a mountable [`ForensicFs`].
362///
363/// Transparently peels an OUTER compression wrapper (`evidence.dd.gz` -> `dd`)
364/// via `archive-core` — but only when the content magic AND the file extension
365/// agree, so a raw disk with coincidental magic still opens as raw — then hands
366/// the (inner or original) image to the engine's partition-aware `Vfs::open`.
367///
368/// # Errors
369/// Fails loud on a peel decode error, an engine open/decode error, or when the
370/// engine detects no filesystem in the evidence (`InvalidData`).
371pub fn open_image(path: &Path) -> io::Result<Box<dyn ForensicFs + Send>> {
372    if let Some(tmp) = try_peel_to_tmp(path)? {
373        let fs = mount_engine(tmp.path())?;
374        return Ok(Box::new(EngineFs::new(fs, Some(tmp.into_temp_path()))));
375    }
376    let fs = mount_engine(path)?;
377    Ok(Box::new(EngineFs::new(fs, None)))
378}
379
380/// Open a disk-image evidence file into the ADR-0010 unified mount layout:
381/// `<mount>/<volume>/<fs tree>` at **constant depth**, so a consumer walks the
382/// same shape whether the image holds one filesystem or many.
383///
384/// [`open_image`] mounts only the first filesystem the engine finds; on a Windows
385/// GPT disk that is the tiny FAT EFI System Partition, so the NTFS Windows volume
386/// is unreachable. This opens all partitions via `Vfs::open_all` and wraps
387/// **every** result — one or many — in a [`MultiPartitionFs`], so each filesystem
388/// is a `<volume>/` directory under a synthetic root:
389///
390/// * A **bare, unpartitioned** filesystem (no volume table) is one volume named
391///   `root`.
392/// * A **partitioned** disk names each volume by the ADR-0010 precedence
393///   ([`volume_dir_name`]): a wired label (kept verbatim, only unsafe characters
394///   reversibly percent-encoded), else `_partition<index+1>`.
395///
396/// The dense per-partition inode multiplexing (see [`MultiPartitionFs`]) keeps
397/// each volume's inode space disjoint while flowing through the FUSE mount layer
398/// exactly like a single filesystem.
399///
400/// Transparently peels an OUTER compression wrapper first (as [`open_image`]),
401/// keeping the spilled temp image alive for the mount's lifetime.
402///
403/// # Errors
404/// Fails loud on a peel decode error, an engine open/decode error, or when no
405/// partition carries a detectable filesystem (`InvalidData`).
406pub fn open_image_all(path: &Path) -> io::Result<Box<dyn ForensicFs + Send>> {
407    // Peel an outer compression wrapper; the spilled temp file must outlive
408    // whatever we return, so it is threaded through to the mounted backend.
409    let (image, tmp): (PathBuf, Option<tempfile::TempPath>) = match try_peel_to_tmp(path)? {
410        Some(nt) => (nt.path().to_path_buf(), Some(nt.into_temp_path())),
411        None => (path.to_path_buf(), None),
412    };
413
414    let evidences = Vfs::new()
415        .open_all(&image)
416        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
417    // Keep each evidence's locator (its `Locator`) alongside the mounted fs — the
418    // `Layer::Volume { index }` in that chain drives the `_partition<N>` naming.
419    let pairs: Vec<(Locator, DynFs)> = evidences
420        .into_iter()
421        .filter_map(|e| e.fs.map(|fs| (e.root, fs)))
422        .collect();
423
424    if pairs.is_empty() {
425        return Err(io::Error::new(
426            io::ErrorKind::InvalidData,
427            format!(
428                "no filesystem detected in {} (unsupported container/volume/filesystem, or empty image)",
429                image.display()
430            ),
431        ));
432    }
433
434    // ADR-0010: wrap every image — one filesystem or many — in the volume
435    // multiplexer, so the layout is `<mount>/<volume>/<fs tree>` at constant
436    // depth. A single filesystem is just one `<volume>`.
437    let mut parts = Vec::with_capacity(pairs.len());
438    let mut labels = Vec::with_capacity(pairs.len());
439    let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
440    for (spec, fs) in pairs {
441        // The label HOOK — the single place a wired volume label lights up.
442        let label = volume_label(&spec, &fs);
443        let name = volume_dir_name(volume_index(&spec), label, &used);
444        used.insert(name.clone());
445        labels.push(name.into_bytes());
446        parts.push(EngineFs::new(fs, None));
447    }
448    Ok(Box::new(MultiPartitionFs::new(parts, labels, tmp)))
449}
450
451/// The volume-label HOOK for a resolved evidence (ADR-0010 naming precedence,
452/// step 1). It reads the mounted filesystem's own label via the
453/// [`forensic_vfs::FileSystem::volume_label`] accessor (NTFS `$VOLUME_NAME`,
454/// FAT/exFAT label, ext4 `s_volume_name`, APFS volume name) — e.g. a Windows
455/// disk's `System Reserved` partition. `None` when the volume is unlabeled or
456/// the reader does not extract one, in which case [`volume_dir_name`] falls
457/// through to the `_partition<N>` / `root` steps.
458fn volume_label(_spec: &Locator, fs: &DynFs) -> Option<String> {
459    fs.volume_label()
460}
461
462/// The `Layer::Volume { index }` in an evidence's locator chain, if any. A bare
463/// (unpartitioned) filesystem's chain has no `Volume` layer, so this is `None`
464/// and the volume renders as `root`.
465fn volume_index(spec: &Locator) -> Option<usize> {
466    spec.layers().into_iter().find_map(|l| match l {
467        Layer::Volume { index, .. } => Some(*index),
468        _ => None,
469    })
470}
471
472/// The `<volume>/` directory name for one resolved volume, per the ADR-0010
473/// precedence:
474///
475/// 1. a wired **label** — sanitized ([`sanitize_volume_label`]) and used verbatim
476///    when it is non-empty and free of collision;
477/// 2. else `_partition<index+1>` when the locator carries a `Layer::Volume`;
478/// 3. else `root` — a bare, unpartitioned filesystem.
479///
480/// An empty-after-sanitization or colliding label falls back to
481/// `_partition<index+1>` (or `root` when there is no volume index). Pure and
482/// deterministic, so the precedence is unit-tested directly.
483fn volume_dir_name(
484    volume_index: Option<usize>,
485    label: Option<String>,
486    used: &std::collections::HashSet<String>,
487) -> String {
488    if let Some(raw) = label {
489        let sanitized = sanitize_volume_label(&raw);
490        if !sanitized.is_empty() && !used.contains(&sanitized) {
491            return sanitized;
492        }
493    }
494    match volume_index {
495        Some(idx) => format!("_partition{}", idx + 1),
496        None => "root".to_string(),
497    }
498}
499
500/// Reversibly percent-encode the characters ADR-0010 forbids in a `<volume>/`
501/// name, keeping spaces, case, and Unicode verbatim. Encoded: `%` (the escape
502/// introducer, so the transform is reversible), `/` (a path separator), NUL and
503/// all control characters, the Unicode bidirectional formatting/override
504/// characters (spoofing-resistant paths), and — only on Windows — the
505/// Dokan-reserved filename set. Each encoded character becomes `%XX` per UTF-8
506/// byte (e.g. `/` → `%2F`, U+202E → `%E2%80%AE`).
507fn sanitize_volume_label(label: &str) -> String {
508    const HEX: &[u8; 16] = b"0123456789ABCDEF";
509    let mut out = String::with_capacity(label.len());
510    for ch in label.chars() {
511        if should_percent_encode(ch) {
512            let mut buf = [0u8; 4];
513            for b in ch.encode_utf8(&mut buf).bytes() {
514                out.push('%');
515                out.push(HEX[(b >> 4) as usize] as char);
516                out.push(HEX[(b & 0x0F) as usize] as char);
517            }
518        } else {
519            out.push(ch);
520        }
521    }
522    out
523}
524
525/// Whether a character must be percent-encoded in a `<volume>/` name (see
526/// [`sanitize_volume_label`]).
527fn should_percent_encode(ch: char) -> bool {
528    // '%' is the escape introducer — encode it so the transform round-trips.
529    if ch == '%' || ch == '/' {
530        return true;
531    }
532    // NUL, the C0/C1 control ranges, and DEL.
533    if ch.is_control() {
534        return true;
535    }
536    // Bidirectional formatting / override characters (LRM/RLM/LRE…RLO/isolates).
537    if matches!(ch,
538        '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
539    {
540        return true;
541    }
542    // The Windows/Dokan-reserved filename characters (`/` already handled above).
543    #[cfg(windows)]
544    if matches!(ch, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') {
545        return true;
546    }
547    false
548}
549
550/// Attempt to peel one outer compression wrapper, spilling the inner image to a
551/// temp file. Returns `None` when `path` is not a compression wrapper (so the
552/// caller opens it directly), and an error only when a genuinely-named wrapper
553/// fails to decode. Mirrors `disk_forensic::container::try_peel`.
554fn try_peel_to_tmp(path: &Path) -> io::Result<Option<tempfile::NamedTempFile>> {
555    use std::io::{Read, Write};
556
557    let name = path.file_name().and_then(|n| n.to_str());
558    // Sniff the head only — never slurp a large non-wrapper image. Only
559    // compression wrappers are peeled here; the sniff/decode/guard policy (incl.
560    // the coincidental-magic guard) lives once in archive_core::peel_archive.
561    let mut head = [0u8; 16];
562    let read = {
563        let mut file = std::fs::File::open(path)?;
564        file.read(&mut head)?
565    };
566    if !archive_core::sniff(name, &head[..read]).is_compression_wrapper() {
567        return Ok(None);
568    }
569    let data = std::fs::read(path)?;
570    match archive_core::peel_archive(&data, name, &archive_core::Limits::default()) {
571        Ok(archive_core::Peel::Inner(inner)) => {
572            let mut tmp = tempfile::Builder::new().suffix(".img").tempfile()?;
573            tmp.write_all(&inner)?;
574            tmp.flush()?;
575            Ok(Some(tmp))
576        }
577        Ok(archive_core::Peel::NotPacked) => Ok(None),
578        Err(e) => Err(io::Error::new(
579            io::ErrorKind::InvalidData,
580            format!("archive peel failed: {e}"),
581        )),
582    }
583}
584
585/// Run the engine's partition-aware open on `path` and require a filesystem.
586fn mount_engine(path: &Path) -> io::Result<DynFs> {
587    let evidence = Vfs::new()
588        .open(path)
589        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
590    evidence.fs.ok_or_else(|| {
591        io::Error::new(
592            io::ErrorKind::InvalidData,
593            format!(
594                "no filesystem detected in {} (unsupported container/volume/filesystem, or empty image)",
595                path.display()
596            ),
597        )
598    })
599}
600
601/// The synthetic-root inode of a [`MultiPartitionFs`].
602const MP_ROOT_INO: u64 = 1;
603
604/// The ADR-0010 volume multiplexer: it surfaces each volume of a disk image as a
605/// `<volume>/` subdirectory under a synthetic root (`_partition<N>`, a wired
606/// label, or `root` for a bare unpartitioned filesystem — see
607/// [`volume_dir_name`]), so an analyst reaches every filesystem (e.g. both the
608/// FAT EFI System Partition *and* the NTFS Windows volume of a GPT disk) rather
609/// than only the first the engine finds, at a constant `<mount>/<volume>/…`
610/// depth even for a single-filesystem image.
611///
612/// Each volume is a mounted [`EngineFs`]; the multiplexer keeps a dense
613/// `(partition, inner inode) -> global inode` map so partition inode spaces stay
614/// disjoint. A `partition << 48` bit-pack would be simpler but overflows the FUSE
615/// mount layer's `ro_ino` (backend `+ 1000`) / `decode_fuse_ino` namespace
616/// `[1000, 10_000_000)`; the dense allocator (the same pattern `EngineFs` uses
617/// for `FileId -> u64`) keeps globals small, so the tree flows through the mount
618/// exactly like a single filesystem.
619pub struct MultiPartitionFs {
620    /// One mounted filesystem per surfaced partition, in disk order. Declared
621    /// before `_tmp` so its open handles drop before the temp image is unlinked
622    /// (correct on Windows).
623    parts: Vec<EngineFs>,
624    /// The ADR-0010 `<volume>/` directory name for each partition (parallel to
625    /// `parts`) — `_partition<N>`, a wired label, or `root`.
626    labels: Vec<Vec<u8>>,
627    /// Dense `(partition, inner inode) -> global inode` and its inverse.
628    fwd: HashMap<(usize, u64), u64>,
629    rev: HashMap<u64, (usize, u64)>,
630    /// Next dense global inode to hand out (starts above the synthetic root).
631    next: u64,
632    /// A peeled inner image spilled to a temp file, removed when this drops.
633    _tmp: Option<tempfile::TempPath>,
634}
635
636impl MultiPartitionFs {
637    /// Wrap the per-partition filesystems and their labels. `tmp` is the temp
638    /// file backing a peeled image, if any — kept alive for the mount's lifetime.
639    fn new(parts: Vec<EngineFs>, labels: Vec<Vec<u8>>, tmp: Option<tempfile::TempPath>) -> Self {
640        debug_assert_eq!(parts.len(), labels.len());
641        Self {
642            parts,
643            labels,
644            fwd: HashMap::new(),
645            rev: HashMap::new(),
646            next: MP_ROOT_INO + 1,
647            _tmp: tmp,
648        }
649    }
650
651    /// Map a `(partition, inner inode)` pair to a stable dense global inode,
652    /// allocating on first sight.
653    fn assign(&mut self, part: usize, inner: u64) -> u64 {
654        if let Some(&global) = self.fwd.get(&(part, inner)) {
655            return global;
656        }
657        let global = self.next;
658        self.next += 1;
659        self.fwd.insert((part, inner), global);
660        self.rev.insert(global, (part, inner));
661        global
662    }
663
664    /// Resolve a global inode back to its `(partition, inner inode)`, loud on miss.
665    fn resolve(&self, ino: u64) -> FsResult<(usize, u64)> {
666        self.rev
667            .get(&ino)
668            .copied()
669            .ok_or_else(|| FsError::NotFound(format!("unknown inode {ino}")))
670    }
671
672    /// Resolve a non-root inode for a byte-producing op, rejecting the synthetic
673    /// root (it is a directory, not a file).
674    fn dispatch_file(&self, ino: u64) -> FsResult<(usize, u64)> {
675        if ino == MP_ROOT_INO {
676            return Err(FsError::Other(
677                "the multi-partition root is a directory, not a file".to_string(),
678            ));
679        }
680        self.resolve(ino)
681    }
682}
683
684/// Metadata for the synthetic multi-partition root: a read-only directory.
685fn synthetic_root_metadata() -> FsMetadata {
686    FsMetadata {
687        ino: MP_ROOT_INO,
688        file_type: FsFileType::Directory,
689        mode: 0o040_555,
690        uid: 0,
691        gid: 0,
692        size: 0,
693        links_count: 2,
694        atime: FsTimestamp::default(),
695        mtime: FsTimestamp::default(),
696        ctime: FsTimestamp::default(),
697        crtime: FsTimestamp::default(),
698        allocated: true,
699    }
700}
701
702impl ForensicFs for MultiPartitionFs {
703    fn root_ino(&self) -> u64 {
704        MP_ROOT_INO
705    }
706
707    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
708        if ino == MP_ROOT_INO {
709            let mut out = Vec::with_capacity(self.parts.len());
710            for idx in 0..self.parts.len() {
711                let inner_root = self.parts[idx].root_ino();
712                let inode = self.assign(idx, inner_root);
713                out.push(FsDirEntry {
714                    inode,
715                    name: self.labels[idx].clone(),
716                    file_type: FsFileType::Directory,
717                });
718            }
719            return Ok(out);
720        }
721        let (part, inner) = self.resolve(ino)?;
722        let entries = self.parts[part].read_dir(inner)?;
723        let mut out = Vec::with_capacity(entries.len());
724        for e in entries {
725            let inode = self.assign(part, e.inode);
726            out.push(FsDirEntry {
727                inode,
728                name: e.name,
729                file_type: e.file_type,
730            });
731        }
732        Ok(out)
733    }
734
735    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
736        if parent_ino == MP_ROOT_INO {
737            if name == b"." || name == b".." {
738                return Ok(Some(MP_ROOT_INO));
739            }
740            for idx in 0..self.parts.len() {
741                if self.labels[idx].as_slice() == name {
742                    let inner_root = self.parts[idx].root_ino();
743                    return Ok(Some(self.assign(idx, inner_root)));
744                }
745            }
746            return Ok(None);
747        }
748        let (part, inner) = self.resolve(parent_ino)?;
749        match self.parts[part].lookup(inner, name)? {
750            Some(child) => Ok(Some(self.assign(part, child))),
751            None => Ok(None),
752        }
753    }
754
755    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
756        if ino == MP_ROOT_INO {
757            return Ok(synthetic_root_metadata());
758        }
759        let (part, inner) = self.resolve(ino)?;
760        let mut meta = self.parts[part].metadata(inner)?;
761        // Re-stamp the metadata's inode with the global one the caller passed.
762        meta.ino = ino;
763        Ok(meta)
764    }
765
766    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
767        let (part, inner) = self.dispatch_file(ino)?;
768        self.parts[part].read_file(inner)
769    }
770
771    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
772        let (part, inner) = self.dispatch_file(ino)?;
773        self.parts[part].read_file_range(inner, offset, len)
774    }
775
776    fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
777        let (part, inner) = self.dispatch_file(ino)?;
778        self.parts[part].read_link(inner)
779    }
780
781    fn block_size(&self) -> u64 {
782        self.parts.first().map_or(4096, ForensicFs::block_size)
783    }
784}
785
786#[cfg(test)]
787mod layout_tests {
788    //! ADR-0010 unified `<volume>/` naming: the precedence
789    //! (label → `_partition<index+1>` → `root`) and the reversible
790    //! percent-sanitization, proven directly against the pure helpers so the
791    //! label HOOK is exercised even though no leaf label accessor is wired yet.
792    use super::{sanitize_volume_label, volume_dir_name};
793    use std::collections::HashSet;
794
795    #[test]
796    fn label_kept_verbatim_including_spaces_and_unicode() {
797        let used = HashSet::new();
798        assert_eq!(
799            volume_dir_name(Some(0), Some("System Reserved".to_string()), &used),
800            "System Reserved",
801            "a label keeps its spaces/case verbatim (ADR-0010)"
802        );
803        assert_eq!(
804            sanitize_volume_label("Café"),
805            "Café",
806            "Unicode is kept verbatim"
807        );
808    }
809
810    #[test]
811    fn label_slash_is_percent_encoded() {
812        let used = HashSet::new();
813        assert_eq!(
814            volume_dir_name(Some(1), Some("a/b".to_string()), &used),
815            "a%2Fb",
816            "`/` is reversibly percent-encoded so it cannot split the path"
817        );
818    }
819
820    #[test]
821    fn no_label_with_volume_layer_is_partition_index_plus_one() {
822        let used = HashSet::new();
823        assert_eq!(volume_dir_name(Some(0), None, &used), "_partition1");
824        assert_eq!(volume_dir_name(Some(2), None, &used), "_partition3");
825    }
826
827    #[test]
828    fn no_label_no_volume_layer_is_root() {
829        let used = HashSet::new();
830        assert_eq!(
831            volume_dir_name(None, None, &used),
832            "root",
833            "a bare unpartitioned filesystem renders as a single `root` volume"
834        );
835    }
836
837    #[test]
838    fn empty_or_colliding_label_falls_back_to_partition() {
839        let mut used = HashSet::new();
840        assert_eq!(
841            volume_dir_name(Some(0), Some(String::new()), &used),
842            "_partition1",
843            "an empty sanitized label falls back to the partition index"
844        );
845        used.insert("dup".to_string());
846        assert_eq!(
847            volume_dir_name(Some(1), Some("dup".to_string()), &used),
848            "_partition2",
849            "a colliding label falls back to the partition index"
850        );
851    }
852
853    #[test]
854    fn sanitize_encodes_control_bidi_and_percent_reversibly() {
855        assert_eq!(
856            sanitize_volume_label("x\ty"),
857            "x%09y",
858            "TAB control encoded"
859        );
860        assert_eq!(
861            sanitize_volume_label("a\u{202E}b"),
862            "a%E2%80%AEb",
863            "the RIGHT-TO-LEFT OVERRIDE bidi char is encoded to its UTF-8 bytes"
864        );
865        assert_eq!(
866            sanitize_volume_label("50%"),
867            "50%25",
868            "`%` is escaped for reversibility"
869        );
870        assert_eq!(sanitize_volume_label("NUL\0x"), "NUL%00x", "NUL encoded");
871    }
872}