#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use forensic_vfs::{
Allocation, DynFs, FileId, Layer, Locator, MacbTimes, NodeKind, StreamId, TimeStamp,
TimeZonePolicy, VfsError,
};
use forensic_vfs_engine::Vfs;
use crate::{
not_supported, ForensicFs, FsAllocation, FsBlockRange, FsDeletedInode, FsDeletedNode,
FsDirEntry, FsError, FsFileType, FsMetadata, FsRecoveryResult, FsResult, FsTimelineEvent,
FsTimestamp, FsTransaction,
};
const ENUM_CAP: usize = 100_000;
pub struct EngineFs {
fs: DynFs,
fwd: HashMap<FileId, u64>,
rev: HashMap<u64, FileId>,
next: u64,
root_u64: u64,
_tmp: Option<tempfile::TempPath>,
}
impl EngineFs {
fn new(fs: DynFs, tmp: Option<tempfile::TempPath>) -> Self {
let root = fs.root();
let mut this = Self {
fs,
fwd: HashMap::new(),
rev: HashMap::new(),
next: 10,
root_u64: 0,
_tmp: tmp,
};
this.root_u64 = this.assign(root);
this
}
fn assign(&mut self, id: FileId) -> u64 {
if let Some(&ino) = self.fwd.get(&id) {
return ino;
}
let ino = self.next;
self.next += 1;
self.fwd.insert(id, ino);
self.rev.insert(ino, id);
ino
}
fn file_id(&self, ino: u64) -> FsResult<FileId> {
self.rev
.get(&ino)
.copied()
.ok_or_else(|| FsError::NotFound(format!("unknown inode {ino}")))
}
#[must_use]
pub fn fs_kind_str(&self) -> &'static str {
self.fs.kind().as_str()
}
}
fn vfs_err(e: VfsError) -> FsError {
FsError::Other(e.to_string())
}
fn node_kind(k: NodeKind) -> FsFileType {
match k {
NodeKind::File => FsFileType::RegularFile,
NodeKind::Dir => FsFileType::Directory,
NodeKind::Symlink => FsFileType::Symlink,
NodeKind::Device => FsFileType::CharDevice,
_ => FsFileType::Unknown,
}
}
fn ts(t: Option<TimeStamp>) -> FsTimestamp {
match t {
Some(t) => FsTimestamp {
seconds: (t.unix_nanos.div_euclid(1_000_000_000)) as i64,
nanoseconds: (t.unix_nanos.rem_euclid(1_000_000_000)) as u32,
},
None => FsTimestamp::default(),
}
}
fn to_metadata(ino: u64, meta: &forensic_vfs::FsMeta, times: &MacbTimes) -> FsMetadata {
let file_type = node_kind(meta.kind);
let mode = meta.mode.map_or_else(
|| match file_type {
FsFileType::Directory => 0o040_755,
FsFileType::Symlink => 0o120_777,
_ => 0o100_644,
},
|m| (m & 0xFFFF) as u16,
);
FsMetadata {
ino,
file_type,
mode,
uid: meta.uid.unwrap_or(0),
gid: meta.gid.unwrap_or(0),
size: meta.size,
links_count: meta.nlink.min(u32::from(u16::MAX)) as u16,
atime: ts(times.accessed),
mtime: ts(times.modified),
ctime: ts(times.changed),
crtime: ts(times.born),
allocated: matches!(meta.allocated, Allocation::Allocated),
}
}
impl ForensicFs for EngineFs {
fn root_ino(&self) -> u64 {
self.root_u64
}
fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
let id = self.file_id(ino)?;
let stream = self.fs.read_dir(id).map_err(vfs_err)?;
let mut out = Vec::new();
for entry in stream {
let entry = entry.map_err(vfs_err)?;
let child = self.assign(entry.id);
out.push(FsDirEntry {
inode: child,
name: entry.name,
file_type: node_kind(entry.kind),
});
}
Ok(out)
}
fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
let parent = self.file_id(parent_ino)?;
match self.fs.lookup(parent, name).map_err(vfs_err)? {
Some(id) => Ok(Some(self.assign(id))),
None => Ok(None),
}
}
fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
let id = self.file_id(ino)?;
let meta = self.fs.meta(id).map_err(vfs_err)?;
Ok(to_metadata(ino, &meta, &meta.times))
}
fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
let id = self.file_id(ino)?;
let size = self.fs.meta(id).map_err(vfs_err)?.size;
self.read_file_range(ino, 0, size)
}
fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
let id = self.file_id(ino)?;
let mut buf = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
let mut filled = 0usize;
while filled < buf.len() {
let n = self
.fs
.read_at(
id,
StreamId::Default,
offset + filled as u64,
&mut buf[filled..],
)
.map_err(vfs_err)?;
if n == 0 {
break;
}
filled += n;
}
buf.truncate(filled);
Ok(buf)
}
fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
let id = self.file_id(ino)?;
self.fs.read_link(id, 4096).map_err(vfs_err)
}
fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
let stream = self.fs.deleted().map_err(vfs_err)?;
let mut out = Vec::new();
for meta in stream.take(ENUM_CAP) {
let meta = meta.map_err(vfs_err)?;
out.push(FsDeletedInode {
ino: meta.ino,
file_type: node_kind(meta.kind),
size: meta.size,
dtime: 0,
recoverability: 0.0,
});
}
Ok(out)
}
fn deleted_nodes(&mut self) -> FsResult<Vec<FsDeletedNode>> {
let stream = self.fs.deleted_nodes().map_err(vfs_err)?;
let mut out = Vec::new();
for node in stream.take(ENUM_CAP) {
let node = node.map_err(vfs_err)?;
let ino = self.assign(node.id);
let parent_ino = node.parent.map(|p| self.assign(p));
let meta = &node.meta;
let allocation = match meta.allocated {
Allocation::Orphan => FsAllocation::Orphan,
_ => FsAllocation::Deleted,
};
out.push(FsDeletedNode {
ino,
name: node.name.clone(),
parent_ino,
size: meta.size,
file_type: node_kind(meta.kind),
allocation,
record_id: meta.ino,
atime: ts(meta.times.accessed),
mtime: ts(meta.times.modified),
ctime: ts(meta.times.changed),
crtime: ts(meta.times.born),
});
}
Ok(out)
}
fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
let _ = ino;
Err(not_supported(
"recover_file (the forensic-vfs FileSystem trait has no deleted-content read path)",
))
}
fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
Err(not_supported(
"timeline (the forensic-vfs FileSystem trait has no event-timeline surface)",
))
}
fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
let bs = self.block_size().max(1);
let stream = self.fs.unallocated().map_err(vfs_err)?;
let mut out = Vec::new();
for run in stream.take(ENUM_CAP) {
let run = run.map_err(vfs_err)?;
out.push(FsBlockRange {
start: run.run.image_offset,
length: (run.run.len / bs).max(1),
});
}
Ok(out)
}
fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
Err(not_supported(
"read_unallocated (the forensic-vfs FileSystem trait has no raw-image byte reader)",
))
}
fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
Err(not_supported(
"journal_transactions (the forensic-vfs FileSystem trait has no journal surface)",
))
}
fn fs_info(&self) -> FsResult<serde_json::Value> {
let sizes = self.fs.sector_sizes();
let zone = match self.fs.timestamp_zone() {
TimeZonePolicy::Utc => "utc".to_string(),
TimeZonePolicy::LocalUnknown => "local-unknown".to_string(),
TimeZonePolicy::Local { minutes_east } => format!("local+{minutes_east}m"),
_ => "unknown".to_string(),
};
Ok(serde_json::json!({
"filesystem": self.fs.kind().as_str(),
"logical_sector_size": sizes.logical,
"physical_sector_size": sizes.physical,
"cluster_or_block_size": sizes.cluster_or_block,
"timestamp_zone": zone,
}))
}
fn block_size(&self) -> u64 {
let bs = self.fs.sector_sizes().cluster_or_block;
if bs == 0 {
4096
} else {
u64::from(bs)
}
}
}
pub fn open_image(path: &Path) -> io::Result<Box<dyn ForensicFs + Send>> {
if let Some(tmp) = try_peel_to_tmp(path)? {
let fs = mount_engine(tmp.path())?;
return Ok(Box::new(EngineFs::new(fs, Some(tmp.into_temp_path()))));
}
let fs = mount_engine(path)?;
Ok(Box::new(EngineFs::new(fs, None)))
}
pub fn open_image_all(path: &Path) -> io::Result<Box<dyn ForensicFs + Send>> {
let (image, tmp): (PathBuf, Option<tempfile::TempPath>) = match try_peel_to_tmp(path)? {
Some(nt) => (nt.path().to_path_buf(), Some(nt.into_temp_path())),
None => (path.to_path_buf(), None),
};
let evidences = Vfs::new()
.open_all(&image)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
let pairs: Vec<(Locator, DynFs)> = evidences
.into_iter()
.filter_map(|e| e.fs.map(|fs| (e.root, fs)))
.collect();
if pairs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"no filesystem detected in {} (unsupported container/volume/filesystem, or empty image)",
image.display()
),
));
}
let mut parts = Vec::with_capacity(pairs.len());
let mut labels = Vec::with_capacity(pairs.len());
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
for (spec, fs) in pairs {
let label = volume_label(&spec, &fs);
let name = volume_dir_name(volume_index(&spec), label, &used);
used.insert(name.clone());
labels.push(name.into_bytes());
parts.push(EngineFs::new(fs, None));
}
Ok(Box::new(MultiPartitionFs::new(parts, labels, tmp)))
}
fn volume_label(_spec: &Locator, fs: &DynFs) -> Option<String> {
fs.volume_label()
}
fn volume_index(spec: &Locator) -> Option<usize> {
spec.layers().into_iter().find_map(|l| match l {
Layer::Volume { index, .. } => Some(*index),
_ => None,
})
}
fn volume_dir_name(
volume_index: Option<usize>,
label: Option<String>,
used: &std::collections::HashSet<String>,
) -> String {
if let Some(raw) = label {
let sanitized = sanitize_volume_label(&raw);
if !sanitized.is_empty() && !used.contains(&sanitized) {
return sanitized;
}
}
match volume_index {
Some(idx) => format!("_partition{}", idx + 1),
None => "root".to_string(),
}
}
fn sanitize_volume_label(label: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(label.len());
for ch in label.chars() {
if should_percent_encode(ch) {
let mut buf = [0u8; 4];
for b in ch.encode_utf8(&mut buf).bytes() {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0F) as usize] as char);
}
} else {
out.push(ch);
}
}
out
}
fn should_percent_encode(ch: char) -> bool {
if ch == '%' || ch == '/' {
return true;
}
if ch.is_control() {
return true;
}
if matches!(ch,
'\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
{
return true;
}
#[cfg(windows)]
if matches!(ch, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') {
return true;
}
false
}
fn try_peel_to_tmp(path: &Path) -> io::Result<Option<tempfile::NamedTempFile>> {
use std::io::{Read, Write};
let name = path.file_name().and_then(|n| n.to_str());
let mut head = [0u8; 16];
let read = {
let mut file = std::fs::File::open(path)?;
file.read(&mut head)?
};
if !archive_core::sniff(name, &head[..read]).is_compression_wrapper() {
return Ok(None);
}
let data = std::fs::read(path)?;
match archive_core::peel_archive(&data, name, &archive_core::Limits::default()) {
Ok(archive_core::Peel::Inner(inner)) => {
let mut tmp = tempfile::Builder::new().suffix(".img").tempfile()?;
tmp.write_all(&inner)?;
tmp.flush()?;
Ok(Some(tmp))
}
Ok(archive_core::Peel::NotPacked) => Ok(None),
Err(e) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("archive peel failed: {e}"),
)),
}
}
fn mount_engine(path: &Path) -> io::Result<DynFs> {
let evidence = Vfs::new()
.open(path)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
evidence.fs.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"no filesystem detected in {} (unsupported container/volume/filesystem, or empty image)",
path.display()
),
)
})
}
const MP_ROOT_INO: u64 = 1;
pub struct MultiPartitionFs {
parts: Vec<EngineFs>,
labels: Vec<Vec<u8>>,
fwd: HashMap<(usize, u64), u64>,
rev: HashMap<u64, (usize, u64)>,
next: u64,
_tmp: Option<tempfile::TempPath>,
}
impl MultiPartitionFs {
fn new(parts: Vec<EngineFs>, labels: Vec<Vec<u8>>, tmp: Option<tempfile::TempPath>) -> Self {
debug_assert_eq!(parts.len(), labels.len());
Self {
parts,
labels,
fwd: HashMap::new(),
rev: HashMap::new(),
next: MP_ROOT_INO + 1,
_tmp: tmp,
}
}
fn assign(&mut self, part: usize, inner: u64) -> u64 {
if let Some(&global) = self.fwd.get(&(part, inner)) {
return global;
}
let global = self.next;
self.next += 1;
self.fwd.insert((part, inner), global);
self.rev.insert(global, (part, inner));
global
}
fn resolve(&self, ino: u64) -> FsResult<(usize, u64)> {
self.rev
.get(&ino)
.copied()
.ok_or_else(|| FsError::NotFound(format!("unknown inode {ino}")))
}
fn dispatch_file(&self, ino: u64) -> FsResult<(usize, u64)> {
if ino == MP_ROOT_INO {
return Err(FsError::Other(
"the multi-partition root is a directory, not a file".to_string(),
));
}
self.resolve(ino)
}
}
fn synthetic_root_metadata() -> FsMetadata {
FsMetadata {
ino: MP_ROOT_INO,
file_type: FsFileType::Directory,
mode: 0o040_555,
uid: 0,
gid: 0,
size: 0,
links_count: 2,
atime: FsTimestamp::default(),
mtime: FsTimestamp::default(),
ctime: FsTimestamp::default(),
crtime: FsTimestamp::default(),
allocated: true,
}
}
impl ForensicFs for MultiPartitionFs {
fn root_ino(&self) -> u64 {
MP_ROOT_INO
}
fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
if ino == MP_ROOT_INO {
let mut out = Vec::with_capacity(self.parts.len());
for idx in 0..self.parts.len() {
let inner_root = self.parts[idx].root_ino();
let inode = self.assign(idx, inner_root);
out.push(FsDirEntry {
inode,
name: self.labels[idx].clone(),
file_type: FsFileType::Directory,
});
}
return Ok(out);
}
let (part, inner) = self.resolve(ino)?;
let entries = self.parts[part].read_dir(inner)?;
let mut out = Vec::with_capacity(entries.len());
for e in entries {
let inode = self.assign(part, e.inode);
out.push(FsDirEntry {
inode,
name: e.name,
file_type: e.file_type,
});
}
Ok(out)
}
fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
if parent_ino == MP_ROOT_INO {
if name == b"." || name == b".." {
return Ok(Some(MP_ROOT_INO));
}
for idx in 0..self.parts.len() {
if self.labels[idx].as_slice() == name {
let inner_root = self.parts[idx].root_ino();
return Ok(Some(self.assign(idx, inner_root)));
}
}
return Ok(None);
}
let (part, inner) = self.resolve(parent_ino)?;
match self.parts[part].lookup(inner, name)? {
Some(child) => Ok(Some(self.assign(part, child))),
None => Ok(None),
}
}
fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
if ino == MP_ROOT_INO {
return Ok(synthetic_root_metadata());
}
let (part, inner) = self.resolve(ino)?;
let mut meta = self.parts[part].metadata(inner)?;
meta.ino = ino;
Ok(meta)
}
fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
let (part, inner) = self.dispatch_file(ino)?;
self.parts[part].read_file(inner)
}
fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
let (part, inner) = self.dispatch_file(ino)?;
self.parts[part].read_file_range(inner, offset, len)
}
fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
let (part, inner) = self.dispatch_file(ino)?;
self.parts[part].read_link(inner)
}
fn block_size(&self) -> u64 {
self.parts.first().map_or(4096, ForensicFs::block_size)
}
}
#[cfg(test)]
mod layout_tests {
use super::{sanitize_volume_label, volume_dir_name};
use std::collections::HashSet;
#[test]
fn label_kept_verbatim_including_spaces_and_unicode() {
let used = HashSet::new();
assert_eq!(
volume_dir_name(Some(0), Some("System Reserved".to_string()), &used),
"System Reserved",
"a label keeps its spaces/case verbatim (ADR-0010)"
);
assert_eq!(
sanitize_volume_label("Café"),
"Café",
"Unicode is kept verbatim"
);
}
#[test]
fn label_slash_is_percent_encoded() {
let used = HashSet::new();
assert_eq!(
volume_dir_name(Some(1), Some("a/b".to_string()), &used),
"a%2Fb",
"`/` is reversibly percent-encoded so it cannot split the path"
);
}
#[test]
fn no_label_with_volume_layer_is_partition_index_plus_one() {
let used = HashSet::new();
assert_eq!(volume_dir_name(Some(0), None, &used), "_partition1");
assert_eq!(volume_dir_name(Some(2), None, &used), "_partition3");
}
#[test]
fn no_label_no_volume_layer_is_root() {
let used = HashSet::new();
assert_eq!(
volume_dir_name(None, None, &used),
"root",
"a bare unpartitioned filesystem renders as a single `root` volume"
);
}
#[test]
fn empty_or_colliding_label_falls_back_to_partition() {
let mut used = HashSet::new();
assert_eq!(
volume_dir_name(Some(0), Some(String::new()), &used),
"_partition1",
"an empty sanitized label falls back to the partition index"
);
used.insert("dup".to_string());
assert_eq!(
volume_dir_name(Some(1), Some("dup".to_string()), &used),
"_partition2",
"a colliding label falls back to the partition index"
);
}
#[test]
fn sanitize_encodes_control_bidi_and_percent_reversibly() {
assert_eq!(
sanitize_volume_label("x\ty"),
"x%09y",
"TAB control encoded"
);
assert_eq!(
sanitize_volume_label("a\u{202E}b"),
"a%E2%80%AEb",
"the RIGHT-TO-LEFT OVERRIDE bidi char is encoded to its UTF-8 bytes"
);
assert_eq!(
sanitize_volume_label("50%"),
"50%25",
"`%` is escaped for reversibility"
);
assert_eq!(sanitize_volume_label("NUL\0x"), "NUL%00x", "NUL encoded");
}
}