#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use forensic_vfs::adapters::{FileSource, SeekPoolSource, SourceCursor, SubRange};
use forensic_vfs::read::{le_u32, le_u64};
use forensic_vfs::{
Confidence, ContainerFormat, ContainerOpen, DynFs, DynSource, FileId, FileSystem,
FileSystemOpen, FsKind, FsMeta, Layer, NodeAddr, NodeKind, Openers, PathSpec, SmallHex,
SnapshotRef, SniffWindow, VfsError, VfsResult, VolumeDesc, VolumeKind, VolumeScheme,
VolumeSystem, VolumeSystemOpen,
};
use forensic_vfs_resolver::SourceOpen;
use state_history_forensic::epoch::EpochTag;
pub struct Evidence {
pub root: PathSpec,
pub fs: Option<DynFs>,
}
pub struct Vfs {
openers: Openers,
}
impl Default for Vfs {
fn default() -> Self {
Self::new()
}
}
impl Vfs {
#[must_use]
pub fn new() -> Self {
Self {
openers: default_openers(),
}
}
pub fn open(&self, path: &Path) -> VfsResult<Evidence> {
let base = open_base(path)?;
let base_spec = PathSpec::os(path);
match self.openers.open(base, base_spec.clone(), 0)? {
Some(r) => Ok(Evidence {
root: r.spec,
fs: Some(r.fs),
}),
None => Ok(Evidence {
root: base_spec,
fs: None,
}),
}
}
pub fn open_source(&self, source: DynSource) -> VfsResult<Option<DynFs>> {
let base = PathSpec::root(Layer::Range {
start: 0,
len: source.len(),
});
Ok(self.openers.open(source, base, 0)?.map(|r| r.fs))
}
pub fn snapshots(&self, path: &Path) -> VfsResult<Vec<SnapshotView>> {
let base = open_base(path)?;
let base_spec = PathSpec::os(path);
let Some(resolved) = self.openers.open(base, base_spec, 0)? else {
return Ok(Vec::new());
};
if !is_apfs(&resolved.spec) {
return Ok(Vec::new());
}
let source_spec = resolved.source_spec;
let len = resolved.source.len();
let cursor = SourceCursor::new(resolved.source, 0, len);
let snaps = apfs_core::vfs::ApfsFs::snapshots(cursor).map_err(map_apfs_err)?;
Ok(snaps
.into_iter()
.map(|s| snapshot_view(&source_spec, s.xid, s.name, s.create_time))
.collect())
}
pub fn open_snapshot(&self, path: &Path, xid: u64) -> VfsResult<Evidence> {
let base = open_base(path)?;
let base_spec = PathSpec::os(path);
let resolved = self
.openers
.open(base, base_spec, 0)?
.ok_or(VfsError::Bootstrap {
stage: "apfs snapshot",
detail: "no filesystem detected in evidence".to_string(),
})?;
if !is_apfs(&resolved.spec) {
return Err(VfsError::Unsupported {
layer: "snapshot",
scheme: "non-APFS filesystem has no APFS snapshot".to_string(),
});
}
let source_spec = resolved.source_spec;
let len = resolved.source.len();
let cursor = SourceCursor::new(resolved.source, 0, len);
let fs = apfs_core::vfs::ApfsFs::open_snapshot(cursor, xid).map_err(map_apfs_err)?;
let root = source_spec
.push(Layer::Snapshot {
store: SnapshotRef::ApfsXid(xid),
})
.push(Layer::Fs {
kind: FsKind::APFS,
at: NodeAddr::Path(Vec::new()),
});
Ok(Evidence {
root,
fs: Some(Arc::new(fs)),
})
}
}
#[derive(Debug, Clone)]
pub struct SnapshotView {
pub epoch: EpochTag,
pub xid: u64,
pub name: String,
pub locator: PathSpec,
}
fn is_apfs(spec: &PathSpec) -> bool {
matches!(
spec.layer,
Layer::Fs {
kind: FsKind::APFS,
..
}
)
}
fn snapshot_view(source_spec: &PathSpec, xid: u64, name: String, create_time: u64) -> SnapshotView {
SnapshotView {
epoch: epoch_from_create_time(create_time),
xid,
name,
locator: source_spec.clone().push(Layer::Snapshot {
store: SnapshotRef::ApfsXid(xid),
}),
}
}
fn epoch_from_create_time(create_time_ns: u64) -> EpochTag {
let mut bytes = [0u8; 32];
bytes[24..32].copy_from_slice(&create_time_ns.to_be_bytes());
EpochTag::from_bytes(bytes)
}
#[allow(clippy::needless_pass_by_value)]
fn map_apfs_err(e: apfs_core::ApfsError) -> VfsError {
VfsError::Decode {
layer: "apfs snapshot",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
}
}
#[must_use]
pub fn default_openers() -> Openers {
Openers::new()
.filesystem(NtfsProbe)
.filesystem(Ext4Probe)
.filesystem(XfsProbe)
.filesystem(Iso9660Probe)
.filesystem(ApfsProbe)
.filesystem(HfsPlusProbe)
.filesystem(ExFatProbe)
.filesystem(FatProbe)
.volume_system(GptProbe)
.volume_system(MbrProbe)
.volume_system(ApmProbe)
.container(VhdDecoder)
.container(Qcow2Decoder)
.container(VmdkDecoder)
.container(VhdxDecoder)
.container(DmgDecoder)
.container(Aff4Decoder)
}
fn open_base(path: &Path) -> VfsResult<DynSource> {
if is_ewf(path) {
let reader = ewf::EwfReader::open(path).map_err(|e| VfsError::Bootstrap {
stage: "ewf::open",
detail: e.to_string(),
})?;
Ok(Arc::new(reader))
} else {
Ok(Arc::new(FileSource::open(path)?))
}
}
fn is_ewf(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("e01") || e.eq_ignore_ascii_case("ex01"))
}
struct NtfsProbe;
impl FileSystemOpen for NtfsProbe {
fn kind(&self) -> FsKind {
FsKind::NTFS
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(3, b"NTFS ") {
Confidence::Yes { how: "NTFS OEM id" }
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let fs = ntfs_core::NtfsFs::open(cursor).map_err(|e| VfsError::Decode {
layer: "ntfs",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
Ok(Arc::new(fs))
}
}
struct Ext4Probe;
impl FileSystemOpen for Ext4Probe {
fn kind(&self) -> FsKind {
FsKind::EXT
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(1080, &[0x53, 0xEF]) {
Confidence::Yes {
how: "ext2/3/4 superblock magic",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let fs = ext4fs::Ext4Fs::open(cursor).map_err(|e| VfsError::Decode {
layer: "ext4",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
Ok(Arc::new(fs))
}
}
struct XfsProbe;
impl FileSystemOpen for XfsProbe {
fn kind(&self) -> FsKind {
FsKind::XFS
}
fn probe(&self, w: &SniffWindow) -> Confidence {
xfs::vfs::xfs_probe(w)
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
Ok(Arc::new(xfs::vfs::XfsFs::open(&src)?))
}
}
struct Iso9660Probe;
impl FileSystemOpen for Iso9660Probe {
fn kind(&self) -> FsKind {
FsKind::ISO9660
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(32769, b"CD001") {
Confidence::Yes {
how: "ISO 9660 CD001 volume descriptor",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let fs = iso::vfs::IsoVfs::open(cursor).map_err(|e| VfsError::Decode {
layer: "iso9660",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
Ok(Arc::new(fs))
}
}
struct ApfsProbe;
impl FileSystemOpen for ApfsProbe {
fn kind(&self) -> FsKind {
FsKind::APFS
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(32, b"NXSB") {
Confidence::Yes {
how: "APFS NXSB container superblock",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let fs = apfs_core::vfs::ApfsFs::open(cursor).map_err(|e| VfsError::Decode {
layer: "apfs",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
Ok(Arc::new(fs))
}
}
struct HfsPlusProbe;
impl FileSystemOpen for HfsPlusProbe {
fn kind(&self) -> FsKind {
FsKind::HFS_PLUS
}
fn probe(&self, w: &SniffWindow) -> Confidence {
match w.at(1024, 2) {
Some([0x48, 0x2B | 0x58]) => Confidence::Yes {
how: "HFS+/HFSX volume header",
},
_ => Confidence::No,
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let mut volume = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
let n = src.read_at(0, &mut volume)?;
volume.truncate(n);
let fs = hfsplus::vfs::HfsFs::new(volume)?;
Ok(Arc::new(fs))
}
}
struct ExFatProbe;
impl FileSystemOpen for ExFatProbe {
fn kind(&self) -> FsKind {
FsKind::EXFAT
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.at(510, 2) == Some(&[0x55, 0xaa]) && w.has_magic(3, b"EXFAT ") {
Confidence::Yes {
how: "exFAT boot signature",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let fs = fat::FatFs::open(cursor).map_err(|e| VfsError::Decode {
layer: "exfat",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
Ok(Arc::new(fs))
}
}
struct FatProbe;
impl FileSystemOpen for FatProbe {
fn kind(&self) -> FsKind {
FsKind::FAT
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.at(510, 2) != Some(&[0x55, 0xaa]) {
return Confidence::No;
}
let jump = w.at(0, 1).and_then(|s| s.first().copied());
let jump_ok = matches!(jump, Some(0xEB | 0xE9));
let bps = w
.at(11, 2)
.and_then(|b| <[u8; 2]>::try_from(b).ok())
.map_or(0, u16::from_le_bytes);
if jump_ok && bps.is_power_of_two() && (512..=4096).contains(&bps) {
Confidence::Yes { how: "FAT BPB" }
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynFs> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let fs = fat::FatFs::open(cursor).map_err(|e| VfsError::Decode {
layer: "fat",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
Ok(Arc::new(fs))
}
}
struct MbrProbe;
impl VolumeSystemOpen for MbrProbe {
fn scheme(&self) -> VolumeScheme {
VolumeScheme::Mbr
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.at(510, 2) != Some(&[0x55, 0xaa]) {
return Confidence::No;
}
let data = w.bytes();
for i in 0..4usize {
let base = 446 + i * 16;
let ptype = data.get(base + 4).copied().unwrap_or(0);
let size = le_u32(data, base + 12);
if ptype != 0 && ptype != 0xEE && size != 0 {
return Confidence::Yes {
how: "MBR partition table",
};
}
}
Confidence::No
}
fn open(&self, src: DynSource) -> VfsResult<Box<dyn VolumeSystem>> {
Ok(Box::new(Mbr::parse(src)?))
}
}
struct Mbr {
parent: DynSource,
volumes: Vec<VolumeDesc>,
}
impl Mbr {
fn parse(src: DynSource) -> VfsResult<Self> {
let mut sector = [0u8; 512];
src.read_at(0, &mut sector)?;
let mut volumes = Vec::new();
for i in 0..4usize {
let base = 446 + i * 16;
let ptype = sector.get(base + 4).copied().unwrap_or(0);
let start_lba = le_u32(§or, base + 8);
let size = le_u32(§or, base + 12);
if ptype == 0 || ptype == 0xEE || size == 0 {
continue;
}
volumes.push(VolumeDesc {
index: i,
kind: VolumeKind::Partition,
start: u64::from(start_lba) * 512,
len: u64::from(size) * 512,
type_hint: Some(format!("0x{ptype:02x}")),
label: None,
});
}
Ok(Self {
parent: src,
volumes,
})
}
}
impl VolumeSystem for Mbr {
fn scheme(&self) -> VolumeScheme {
VolumeScheme::Mbr
}
fn volumes(&self) -> &[VolumeDesc] {
&self.volumes
}
fn open_volume(&self, index: usize) -> VfsResult<DynSource> {
let desc = self.volumes.get(index).ok_or(VfsError::OutOfRange {
what: "mbr volume index",
offset: index as u64,
len: 1,
bound: self.volumes.len() as u64,
})?;
Ok(Arc::new(SubRange::new(
self.parent.clone(),
desc.start,
desc.len,
)))
}
}
struct GptProbe;
impl VolumeSystemOpen for GptProbe {
fn scheme(&self) -> VolumeScheme {
VolumeScheme::Gpt
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(512, b"EFI PART") {
Confidence::Yes {
how: "GPT EFI PART header",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<Box<dyn VolumeSystem>> {
Ok(Box::new(Gpt::parse(src)?))
}
}
struct Gpt {
parent: DynSource,
volumes: Vec<VolumeDesc>,
}
impl Gpt {
fn parse(src: DynSource) -> VfsResult<Self> {
let mut header = [0u8; 512];
src.read_at(512, &mut header)?;
if header.get(0..8) != Some(b"EFI PART".as_slice()) {
return Err(VfsError::Decode {
layer: "gpt",
offset: 512,
detail: "missing EFI PART signature".to_string(),
bytes: SmallHex::new(header.get(0..8).unwrap_or(&[])),
});
}
let entries_lba = le_u64(&header, 72);
let num_entries = le_u32(&header, 80).min(256) as usize;
let entry_size = le_u32(&header, 84).clamp(128, 512) as usize;
let array_len = num_entries.checked_mul(entry_size).unwrap_or(0);
let mut arr = vec![0u8; array_len];
src.read_at(entries_lba.saturating_mul(512), &mut arr)?;
let mut volumes = Vec::new();
for i in 0..num_entries {
let Some(base) = i.checked_mul(entry_size) else {
break; };
let Some(entry) = arr.get(base..base.saturating_add(entry_size)) else {
break; };
let type_guid = entry.get(0..16).unwrap_or(&[]);
if type_guid.iter().all(|&b| b == 0) {
continue;
}
let first = le_u64(entry, 32);
let last = le_u64(entry, 40);
if last < first {
continue;
}
let sectors = last - first + 1;
volumes.push(VolumeDesc {
index: i,
kind: VolumeKind::Partition,
start: first.saturating_mul(512),
len: sectors.saturating_mul(512),
type_hint: Some(guid_hint(type_guid)),
label: None,
});
}
Ok(Self {
parent: src,
volumes,
})
}
}
impl VolumeSystem for Gpt {
fn scheme(&self) -> VolumeScheme {
VolumeScheme::Gpt
}
fn volumes(&self) -> &[VolumeDesc] {
&self.volumes
}
fn open_volume(&self, index: usize) -> VfsResult<DynSource> {
let desc = self.volumes.get(index).ok_or(VfsError::OutOfRange {
what: "gpt volume index",
offset: index as u64,
len: 1,
bound: self.volumes.len() as u64,
})?;
Ok(Arc::new(SubRange::new(
self.parent.clone(),
desc.start,
desc.len,
)))
}
}
struct ApmProbe;
impl VolumeSystemOpen for ApmProbe {
fn scheme(&self) -> VolumeScheme {
VolumeScheme::Apm
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(0, b"ER") && w.has_magic(512, b"PM") {
Confidence::Yes {
how: "Apple Partition Map",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<Box<dyn VolumeSystem>> {
Ok(Box::new(Apm::parse(src)?))
}
}
struct Apm {
parent: DynSource,
volumes: Vec<VolumeDesc>,
}
const APM_MAP_CAP: u64 = 256 * 1024;
impl Apm {
fn parse(src: DynSource) -> VfsResult<Self> {
let cap = src.len().clamp(1, APM_MAP_CAP) as usize;
let mut head = vec![0u8; cap];
let n = src.read_at(0, &mut head)?;
let map = apm::parse(head.get(..n).unwrap_or(&[])).ok_or_else(|| VfsError::Decode {
layer: "apm",
offset: 0,
detail: "not an Apple Partition Map".to_string(),
bytes: SmallHex::new(head.get(..2).unwrap_or(&[])),
})?;
let block_size = u64::from(map.block_size.max(1));
let mut volumes = Vec::new();
for (i, part) in map.partitions.iter().enumerate() {
if part.type_name.eq_ignore_ascii_case("Apple_partition_map")
|| part.type_name.eq_ignore_ascii_case("Apple_Free")
|| part.type_name.eq_ignore_ascii_case("Apple_Void")
{
continue;
}
volumes.push(VolumeDesc {
index: i,
kind: VolumeKind::Partition,
start: u64::from(part.start_block) * block_size,
len: u64::from(part.block_count) * block_size,
type_hint: Some(part.type_name.clone()),
label: (!part.name.is_empty()).then(|| part.name.clone()),
});
}
Ok(Self {
parent: src,
volumes,
})
}
}
impl VolumeSystem for Apm {
fn scheme(&self) -> VolumeScheme {
VolumeScheme::Apm
}
fn volumes(&self) -> &[VolumeDesc] {
&self.volumes
}
fn open_volume(&self, index: usize) -> VfsResult<DynSource> {
let desc = self.volumes.get(index).ok_or(VfsError::OutOfRange {
what: "apm volume index",
offset: index as u64,
len: 1,
bound: self.volumes.len() as u64,
})?;
Ok(Arc::new(SubRange::new(
self.parent.clone(),
desc.start,
desc.len,
)))
}
}
fn guid_hint(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
struct VhdDecoder;
impl ContainerOpen for VhdDecoder {
fn format(&self) -> ContainerFormat {
ContainerFormat::Vhd
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(0, b"conectix") {
Confidence::Yes {
how: "VHD conectix footer",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let reader =
vhd::VhdReader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
layer: "vhd",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
let vsize = reader.virtual_disk_size();
Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
}
}
struct Qcow2Decoder;
impl ContainerOpen for Qcow2Decoder {
fn format(&self) -> ContainerFormat {
ContainerFormat::Qcow2
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(0, &[0x51, 0x46, 0x49, 0xfb]) {
Confidence::Yes { how: "QCOW2 magic" }
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let reader =
qcow2::Qcow2Reader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
layer: "qcow2",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
let vsize = reader.virtual_disk_size();
Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
}
}
struct VmdkDecoder;
impl ContainerOpen for VmdkDecoder {
fn format(&self) -> ContainerFormat {
ContainerFormat::Vmdk
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(0, b"KDMV") {
Confidence::Yes {
how: "VMDK KDMV magic",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let boxed: Box<dyn vmdk::ReadSeek + Send> = Box::new(cursor);
let reader = vmdk::VmdkReader::open(boxed).map_err(|e| VfsError::Decode {
layer: "vmdk",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
let vsize = reader.virtual_disk_size();
Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
}
}
struct VhdxDecoder;
impl ContainerOpen for VhdxDecoder {
fn format(&self) -> ContainerFormat {
ContainerFormat::Vhdx
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(0, vhdx::FILE_MAGIC) {
Confidence::Yes {
how: "VHDX file magic",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let reader =
vhdx::VhdxReader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
layer: "vhdx",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
let vsize = reader.virtual_disk_size();
Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
}
}
struct DmgDecoder;
impl ContainerOpen for DmgDecoder {
fn format(&self) -> ContainerFormat {
ContainerFormat::Dmg
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic_from_end(512, b"koly") {
Confidence::Yes {
how: "DMG koly trailer",
}
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let reader = dmg::DmgReader::open(cursor).map_err(|e| VfsError::Decode {
layer: "dmg",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
let vsize = reader.virtual_disk_size();
Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
}
}
struct Aff4Decoder;
impl ContainerOpen for Aff4Decoder {
fn format(&self) -> ContainerFormat {
ContainerFormat::Aff4
}
fn probe(&self, w: &SniffWindow) -> Confidence {
if w.has_magic(0, &[0x50, 0x4b, 0x03, 0x04]) {
Confidence::Maybe
} else {
Confidence::No
}
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> {
let len = src.len();
let cursor = SourceCursor::new(src, 0, len);
let reader =
aff4::Aff4Reader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
layer: "aff4",
offset: 0,
detail: e.to_string(),
bytes: SmallHex::new(&[]),
})?;
let vsize = reader.virtual_disk_size();
Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
}
}
const WALK_MAX_DEPTH: usize = 256;
pub struct WalkEntry {
pub path: Vec<Vec<u8>>,
pub id: FileId,
pub meta: FsMeta,
}
pub fn walk(fs: &dyn FileSystem) -> VfsResult<Vec<WalkEntry>> {
let mut out = Vec::new();
let mut visited: HashSet<FileId> = HashSet::new();
let mut stack: Vec<(Vec<Vec<u8>>, FileId, usize)> = vec![(Vec::new(), fs.root(), 0)];
while let Some((prefix, dir_id, depth)) = stack.pop() {
if depth > WALK_MAX_DEPTH || !visited.insert(dir_id) {
continue;
}
for entry in fs.read_dir(dir_id)? {
let entry = entry?;
if matches!(entry.name.as_slice(), b"." | b"..") {
continue;
}
let mut path = prefix.clone();
path.push(entry.name);
let meta = fs.meta(entry.id)?;
let is_dir = matches!(meta.kind, NodeKind::Dir);
out.push(WalkEntry {
path: path.clone(),
id: entry.id,
meta,
});
if is_dir {
stack.push((path, entry.id, depth + 1));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use forensic_vfs::ImageSource;
use std::io::Write;
struct Mem(Vec<u8>);
impl ImageSource for Mem {
fn len(&self) -> u64 {
self.0.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize> {
let off = usize::try_from(offset).unwrap_or(usize::MAX);
let Some(s) = self.0.get(off..) else {
return Ok(0);
};
let n = s.len().min(buf.len());
buf[..n].copy_from_slice(&s[..n]);
Ok(n)
}
}
fn mem(b: Vec<u8>) -> DynSource {
Arc::new(Mem(b))
}
fn window(b: &[u8]) -> SniffWindow<'_> {
SniffWindow::new(0, b)
}
#[test]
fn default_is_new_and_probers_report_their_kinds() {
let _ = Vfs::default().open_source(mem(vec![0u8; 64])).unwrap();
assert_eq!(NtfsProbe.kind(), FsKind::NTFS);
assert_eq!(MbrProbe.scheme(), VolumeScheme::Mbr);
assert_eq!(GptProbe.scheme(), VolumeScheme::Gpt);
}
#[test]
fn probers_say_no_on_unrecognized_bytes() {
let empty = window(&[]);
assert_eq!(NtfsProbe.probe(&empty), Confidence::No);
assert_eq!(MbrProbe.probe(&empty), Confidence::No);
assert_eq!(GptProbe.probe(&empty), Confidence::No);
let mut prot = vec![0u8; 512];
prot[446 + 4] = 0xEE;
prot[446 + 12] = 1; prot[510] = 0x55;
prot[511] = 0xaa;
assert_eq!(MbrProbe.probe(&window(&prot)), Confidence::No);
}
#[test]
fn ntfs_magic_but_invalid_boot_is_a_loud_error() {
let mut v = vec![0u8; 4096];
v[3..11].copy_from_slice(b"NTFS ");
assert!(Vfs::new().open_source(mem(v)).is_err());
}
#[test]
fn a_garbage_e01_path_fails_loud() {
let mut f = tempfile::Builder::new().suffix(".E01").tempfile().unwrap();
f.write_all(b"not really an EWF image").unwrap();
f.flush().unwrap();
assert!(Vfs::new().open(f.path()).is_err());
}
#[test]
fn gpt_parse_without_signature_errors_and_mbr_volume_index_is_bounded() {
assert!(Gpt::parse(mem(vec![0u8; 1024])).is_err());
let mut d = vec![0u8; 512];
d[446 + 4] = 0x07;
d[446 + 8] = 1; d[446 + 12] = 4; d[510] = 0x55;
d[511] = 0xaa;
let m = Mbr::parse(mem(d)).unwrap();
assert_eq!(m.scheme(), VolumeScheme::Mbr);
assert_eq!(m.volumes().len(), 1);
assert!(m.open_volume(0).is_ok());
assert!(m.open_volume(9).is_err());
}
#[test]
fn apm_maps_partitions_and_errors_on_non_apm() {
let mut img = vec![0u8; 512];
img[0..2].copy_from_slice(b"ER");
img[2..4].copy_from_slice(&512u16.to_be_bytes()); let pm = |map_cnt: u32, pstart: u32, pcnt: u32, ptype: &str| {
let mut e = vec![0u8; 512];
e[0..2].copy_from_slice(b"PM");
e[4..8].copy_from_slice(&map_cnt.to_be_bytes());
e[8..12].copy_from_slice(&pstart.to_be_bytes());
e[0x0c..0x10].copy_from_slice(&pcnt.to_be_bytes());
e[0x30..0x30 + ptype.len()].copy_from_slice(ptype.as_bytes());
e
};
img.extend(pm(2, 1, 63, "Apple_partition_map"));
img.extend(pm(2, 4, 2, "Apple_HFS"));
img.extend(vec![0u8; 4 * 512]);
let apm = Apm::parse(mem(img)).unwrap();
assert_eq!(apm.scheme(), VolumeScheme::Apm);
assert_eq!(apm.volumes().len(), 1); assert_eq!(apm.volumes()[0].start, 4 * 512); assert!(apm.open_volume(0).is_ok());
assert!(apm.open_volume(9).is_err());
assert!(Apm::parse(mem(vec![0u8; 2048])).is_err());
}
#[test]
fn recursion_is_depth_capped_on_a_self_referential_mbr() {
let mut d = vec![0u8; 1024];
d[446 + 4] = 0x83; d[446 + 12] = 2;
d[510] = 0x55;
d[511] = 0xaa;
assert!(Vfs::new().open_source(mem(d)).unwrap().is_none());
}
#[test]
fn container_decoders_report_format_and_error_on_bad_content() {
assert_eq!(VhdDecoder.format(), ContainerFormat::Vhd);
assert_eq!(Qcow2Decoder.format(), ContainerFormat::Qcow2);
assert_eq!(VmdkDecoder.format(), ContainerFormat::Vmdk);
assert_eq!(VhdxDecoder.format(), ContainerFormat::Vhdx);
let mut vhd = vec![0u8; 4096];
vhd[0..8].copy_from_slice(b"conectix");
assert!(Vfs::new().open_source(mem(vhd)).is_err());
let mut q = vec![0u8; 4096];
q[0..4].copy_from_slice(&[0x51, 0x46, 0x49, 0xfb]);
assert!(Vfs::new().open_source(mem(q)).is_err());
let mut v = vec![0u8; 4096];
v[0..4].copy_from_slice(b"KDMV");
assert!(Vfs::new().open_source(mem(v)).is_err());
let mut x = vec![0u8; 4096];
x[0..8].copy_from_slice(vhdx::FILE_MAGIC);
assert!(Vfs::new().open_source(mem(x)).is_err());
}
#[test]
fn dmg_decoder_format_probe_and_open_error() {
assert_eq!(DmgDecoder.format(), ContainerFormat::Dmg);
assert_eq!(
DmgDecoder.probe(&SniffWindow::with_tail(0, &[], 1024, &[0u8; 512])),
Confidence::No
);
let mut v = vec![0u8; 1024];
v[512..516].copy_from_slice(b"koly");
v[512 + 224..512 + 232].copy_from_slice(&u64::MAX.to_be_bytes());
assert!(Vfs::new().open_source(mem(v)).is_err());
}
#[test]
fn aff4_decoder_format_probe_and_open_error() {
assert_eq!(Aff4Decoder.format(), ContainerFormat::Aff4);
assert_eq!(Aff4Decoder.probe(&window(&[])), Confidence::No);
assert_eq!(
Aff4Decoder.probe(&window(&[0x50, 0x4b, 0x03, 0x04])),
Confidence::Maybe
);
let mut v = vec![0u8; 256];
v[0..4].copy_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
assert!(Vfs::new().open_source(mem(v)).is_err());
}
#[test]
fn a_valid_container_holding_no_filesystem_resolves_to_none() {
let vhd = include_bytes!("../tests/data/empty.vhd").to_vec();
assert!(Vfs::new().open_source(mem(vhd)).unwrap().is_none());
}
#[test]
fn ext4_probe_kind_and_open_error() {
assert_eq!(Ext4Probe.kind(), FsKind::EXT);
let mut v = vec![0u8; 4096];
v[1080] = 0x53;
v[1081] = 0xef;
v[1048..1052].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
assert!(Vfs::new().open_source(mem(v)).is_err());
}
#[test]
fn iso9660_probe_kind_and_open_error() {
assert_eq!(Iso9660Probe.kind(), FsKind::ISO9660);
assert_eq!(Iso9660Probe.probe(&window(&[])), Confidence::No);
let mut v = vec![0u8; 40 * 1024];
v[32769..32774].copy_from_slice(b"CD001");
assert_eq!(
Iso9660Probe.probe(&window(&v)),
Confidence::Yes {
how: "ISO 9660 CD001 volume descriptor"
}
);
assert!(Vfs::new().open_source(mem(v)).is_err());
}
#[test]
fn apfs_probe_kind_and_open_error() {
assert_eq!(ApfsProbe.kind(), FsKind::APFS);
assert_eq!(ApfsProbe.probe(&window(&[])), Confidence::No);
let mut v = vec![0u8; 40 * 1024];
v[32..36].copy_from_slice(b"NXSB");
assert_eq!(
ApfsProbe.probe(&window(&v)),
Confidence::Yes {
how: "APFS NXSB container superblock"
}
);
assert!(Vfs::new().open_source(mem(v)).is_err());
}
#[test]
fn hfsplus_probe_kind_and_no_on_short_window() {
assert_eq!(HfsPlusProbe.kind(), FsKind::HFS_PLUS);
assert_eq!(HfsPlusProbe.probe(&window(&[])), Confidence::No);
let mut v = vec![0u8; 40 * 1024];
v[1024..1026].copy_from_slice(&[0x48, 0x58]);
assert_eq!(
HfsPlusProbe.probe(&window(&v)),
Confidence::Yes {
how: "HFS+/HFSX volume header"
}
);
}
#[test]
fn fat_and_exfat_magic_but_garbage_are_loud_errors() {
let mut x = vec![0u8; 4096];
x[3..11].copy_from_slice(b"EXFAT ");
x[510] = 0x55;
x[511] = 0xaa;
assert!(Vfs::new().open_source(mem(x)).is_err());
let mut f = vec![0u8; 4096];
f[0] = 0xEB;
f[11..13].copy_from_slice(&512u16.to_le_bytes());
f[510] = 0x55;
f[511] = 0xaa;
assert!(Vfs::new().open_source(mem(f)).is_err());
}
#[test]
fn guid_hint_is_lowercase_hex() {
assert_eq!(guid_hint(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
}
#[test]
fn gpt_parse_skips_unused_and_reversed_entries() {
let mut d = vec![0u8; 1280];
d[512..520].copy_from_slice(b"EFI PART");
d[512 + 72..512 + 80].copy_from_slice(&2u64.to_le_bytes()); d[512 + 80..512 + 84].copy_from_slice(&2u32.to_le_bytes()); d[512 + 84..512 + 88].copy_from_slice(&128u32.to_le_bytes()); d[1024] = 0xa2; d[1024 + 32..1024 + 40].copy_from_slice(&100u64.to_le_bytes());
d[1024 + 40..1024 + 48].copy_from_slice(&200u64.to_le_bytes());
d[1152] = 0xa2;
d[1152 + 32..1152 + 40].copy_from_slice(&500u64.to_le_bytes());
d[1152 + 40..1152 + 48].copy_from_slice(&400u64.to_le_bytes());
let g = Gpt::parse(mem(d)).unwrap();
assert_eq!(g.scheme(), VolumeScheme::Gpt);
assert_eq!(g.volumes().len(), 1, "reversed entry 1 is skipped");
assert_eq!(g.volumes()[0].start, 100 * 512);
assert!(g.open_volume(0).is_ok());
assert!(g.open_volume(7).is_err());
assert_eq!(Mem(vec![1, 2, 3]).read_at(99, &mut [0u8; 4]).unwrap(), 0);
}
const APFS_FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/apfs_volume.bin");
const EXT4_FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ext4.img");
fn apfs_live_xid() -> u64 {
use std::io::{Read, Seek, SeekFrom};
let bytes = std::fs::read(APFS_FIXTURE).unwrap();
let mut c = apfs_core::ApfsContainer::open(std::io::Cursor::new(bytes)).unwrap();
let bs = u64::from(c.superblock().block_size);
let vaddr = c.volume_superblock_addrs().unwrap()[0];
let mut r = c.into_reader();
r.seek(SeekFrom::Start(vaddr * bs)).unwrap();
let mut buf = vec![0u8; bs as usize];
r.read_exact(&mut buf).unwrap();
apfs_core::volume::ApfsVolume::parse(&buf).unwrap().xid()
}
fn zeros_file() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(&[0u8; 4096]).unwrap();
f.flush().unwrap();
f
}
#[test]
fn epoch_from_create_time_round_trips_and_orders() {
let t = 0x0123_4567_89ab_cdefu64;
let tag = epoch_from_create_time(t);
assert_eq!(&tag.0[0..24], &[0u8; 24], "high 24 bytes are zero");
assert_eq!(
u64::from_be_bytes(tag.0[24..32].try_into().unwrap()),
t,
"create_time round-trips out of the low 8 bytes"
);
assert!(
epoch_from_create_time(t + 1).0 > tag.0,
"a later create_time yields a greater tag"
);
}
#[test]
fn snapshot_view_carries_epoch_and_snapshot_locator() {
let base = PathSpec::os("/ev.dmg");
let v = snapshot_view(&base, 42, "daily".to_string(), 1000);
assert_eq!(v.xid, 42);
assert_eq!(v.name, "daily");
assert_eq!(v.epoch, epoch_from_create_time(1000));
assert!(matches!(
v.locator.layer,
Layer::Snapshot {
store: SnapshotRef::ApfsXid(42)
}
));
}
#[test]
fn snapshots_on_unrecognized_source_is_empty() {
let f = zeros_file();
assert!(Vfs::new().snapshots(f.path()).unwrap().is_empty());
}
#[test]
fn snapshots_on_non_apfs_filesystem_is_empty() {
assert!(Vfs::new()
.snapshots(Path::new(EXT4_FIXTURE))
.unwrap()
.is_empty());
}
#[test]
fn open_snapshot_without_filesystem_is_bootstrap_error() {
let f = zeros_file();
assert!(matches!(
Vfs::new().open_snapshot(f.path(), 1),
Err(VfsError::Bootstrap { .. })
));
}
#[test]
fn open_snapshot_on_non_apfs_is_unsupported() {
assert!(matches!(
Vfs::new().open_snapshot(Path::new(EXT4_FIXTURE), 1),
Err(VfsError::Unsupported { .. })
));
}
#[test]
fn open_snapshot_unknown_xid_is_a_loud_decode_error() {
let bogus = apfs_live_xid().wrapping_add(0xDEAD_BEEF);
assert!(matches!(
Vfs::new().open_snapshot(Path::new(APFS_FIXTURE), bogus),
Err(VfsError::Decode { .. })
));
}
#[test]
fn open_snapshot_at_live_xid_mounts_and_walks() {
let ev = Vfs::new()
.open_snapshot(Path::new(APFS_FIXTURE), apfs_live_xid())
.expect("open live-xid snapshot");
let uri = ev.root.to_uri();
assert!(
uri.contains("snapshot:apfs") && uri.contains("fs:apfs"),
"locator names the snapshot + APFS layers: {uri}"
);
let fs = ev.fs.expect("snapshot mounts a filesystem");
let names: Vec<String> = walk(fs.as_ref())
.unwrap()
.into_iter()
.filter_map(|e| {
e.path
.last()
.map(|n| String::from_utf8_lossy(n).to_string())
})
.collect();
assert!(names.iter().any(|n| n == "plain.txt"), "walk: {names:?}");
}
#[test]
fn engine_resolution_matches_openers_open_directly() {
let bytes = std::fs::read(EXT4_FIXTURE).unwrap();
let len = bytes.len() as u64;
let via_engine = Vfs::new()
.open_source(mem(bytes.clone()))
.unwrap()
.expect("engine resolves the ext4 fixture");
let base = PathSpec::root(Layer::Range { start: 0, len });
let resolved = default_openers()
.open(mem(bytes), base, 0)
.unwrap()
.expect("Openers::open resolves the ext4 fixture");
let names = |fs: &dyn FileSystem| {
let mut v: Vec<Vec<Vec<u8>>> = walk(fs).unwrap().into_iter().map(|e| e.path).collect();
v.sort();
v
};
assert_eq!(
names(via_engine.as_ref()),
names(resolved.fs.as_ref()),
"engine and Openers::open mount the same filesystem"
);
assert!(
matches!(
resolved.spec.layer,
Layer::Fs {
kind: FsKind::EXT,
..
}
),
"registry resolved spec tops with fs:ext: {}",
resolved.spec.to_uri()
);
}
}