use forensic_vfs::{
Allocation, ByteRun, Confidence, DirEntry as VfsDirEntry, DirStream, DynSource, ExtentStream,
FileId, FileSystem, FsKind, FsMeta, MacbTimes, NodeKind, NodeStream, ResidencyKind, RunAlloc,
RunFlags, RunInfo, SectorSizes, SmallHex, SniffWindow, StreamId, TimeResolution, TimeSource,
TimeStamp, TimeZonePolicy, VfsError, VfsResult,
};
use crate::dir::list_dir;
use crate::error::UfsError;
use crate::file::{read_file, read_symlink_target};
use crate::inode::{read_inode, FileType, Inode, Timespec, UFS_NDADDR};
use crate::superblock::{Superblock, SBLOCK_UFS1, SBLOCK_UFS2, UFS_ROOTINO};
const UFS1_MAGIC_LE: &[u8] = &[0x54, 0x19, 0x01, 0x00];
const UFS1_MAGIC_BE: &[u8] = &[0x00, 0x01, 0x19, 0x54];
const UFS1_MAGIC_OFF: usize = SBLOCK_UFS1 + 1372;
const UFS2_MAGIC_LE: &[u8] = &[0x19, 0x01, 0x54, 0x19];
const UFS2_MAGIC_BE: &[u8] = &[0x19, 0x54, 0x01, 0x19];
const UFS2_MAGIC_OFF: usize = SBLOCK_UFS2 + 1372;
#[must_use]
pub fn ufs_probe(w: &SniffWindow) -> Confidence {
if w.has_magic(UFS2_MAGIC_OFF, UFS2_MAGIC_LE) || w.has_magic(UFS2_MAGIC_OFF, UFS2_MAGIC_BE) {
return Confidence::Yes {
how: "UFS2 fs_magic 0x19540119 at offset 66908",
};
}
if w.has_magic(UFS1_MAGIC_OFF, UFS1_MAGIC_LE) || w.has_magic(UFS1_MAGIC_OFF, UFS1_MAGIC_BE) {
return Confidence::Yes {
how: "UFS1 fs_magic 0x00011954 at offset 9564",
};
}
Confidence::No
}
pub struct UfsFs {
image: Vec<u8>,
sb: Superblock,
}
impl UfsFs {
pub fn open(source: &DynSource) -> VfsResult<Self> {
let len = source.len();
let cap = usize::try_from(len).unwrap_or(usize::MAX);
let mut image = vec![0u8; cap];
let n = source.read_at(0, &mut image)?;
image.truncate(n);
let sb = Self::parse_primary(&image, SBLOCK_UFS2)
.or_else(|_| Self::parse_primary(&image, SBLOCK_UFS1))
.map_err(map_err)?;
Ok(Self { image, sb })
}
fn parse_primary(image: &[u8], off: usize) -> Result<Superblock, UfsError> {
let slice = image.get(off..).ok_or(UfsError::Truncated {
structure: "superblock (primary offset)",
need: off,
have: image.len(),
})?;
Superblock::parse(slice)
}
fn inode(&self, id: FileId) -> VfsResult<Inode> {
let ino = ino_of(id)?;
read_inode(&self.image, &self.sb, ino).map_err(map_err)
}
}
fn ino_of(id: FileId) -> VfsResult<u64> {
match id {
FileId::Opaque(ino) => Ok(ino),
other => Err(VfsError::Unsupported {
layer: "ufs file-id",
scheme: format!("{other:?}"),
}),
}
}
fn require_default_stream(stream: StreamId) -> VfsResult<()> {
match stream {
StreamId::Default => Ok(()),
other => Err(VfsError::Unsupported {
layer: "ufs stream",
scheme: format!("{other:?}"),
}),
}
}
fn map_err(e: UfsError) -> VfsError {
match e {
UfsError::Truncated { need, have, .. } => VfsError::OutOfRange {
what: "ufs image slice",
offset: need as u64,
len: 1,
bound: have as u64,
},
other => VfsError::Decode {
layer: "ufs",
offset: 0,
detail: other.to_string(),
bytes: SmallHex::new(&[]),
},
}
}
fn node_kind(ft: FileType) -> NodeKind {
match ft {
FileType::Regular => NodeKind::File,
FileType::Directory => NodeKind::Dir,
FileType::Symlink => NodeKind::Symlink,
FileType::CharDevice | FileType::BlockDevice => NodeKind::Device,
FileType::Fifo | FileType::Socket | FileType::Whiteout | FileType::Other(_) => {
NodeKind::Other
}
}
}
fn to_ts(ts: Timespec) -> TimeStamp {
TimeStamp {
unix_nanos: i128::from(ts.sec) * 1_000_000_000 + i128::from(ts.nsec),
source: TimeSource::InodeTable,
resolution: TimeResolution::Nanos,
}
}
impl FileSystem for UfsFs {
fn kind(&self) -> FsKind {
FsKind::UFS
}
fn root(&self) -> FileId {
FileId::Opaque(UFS_ROOTINO)
}
fn sector_sizes(&self) -> SectorSizes {
SectorSizes {
logical: 512,
physical: 512,
cluster_or_block: if self.sb.bsize > 0 {
self.sb.bsize as u32
} else {
0 },
}
}
fn timestamp_zone(&self) -> TimeZonePolicy {
TimeZonePolicy::Utc
}
fn read_dir(&self, ino: FileId) -> VfsResult<DirStream> {
let dir_ino = ino_of(ino)?;
let entries = list_dir(&self.image, &self.sb, dir_ino).map_err(map_err)?;
let out: Vec<VfsResult<VfsDirEntry>> = entries
.into_iter()
.map(|e| {
Ok(VfsDirEntry {
name: e.name,
id: FileId::Opaque(e.ino),
kind: self.entry_kind(e.ino),
})
})
.collect();
Ok(DirStream::new(out.into_iter()))
}
fn extents(&self, ino: FileId, stream: StreamId) -> VfsResult<ExtentStream> {
require_default_stream(stream)?;
let inode = self.inode(ino)?;
let fsize = self.sb.fsize.max(0) as u64;
let bsize = self.sb.bsize.max(0) as u64;
let mut remaining = inode.size;
let mut runs: Vec<VfsResult<RunInfo>> = Vec::new();
for &addr in inode.direct.iter().take(UFS_NDADDR) {
if remaining == 0 {
break;
}
let this = remaining.min(bsize.max(1));
if addr != 0 {
runs.push(Ok(RunInfo {
run: ByteRun {
image_offset: addr.saturating_mul(fsize),
len: this,
flags: RunFlags::default(),
},
alloc: RunAlloc::Allocated,
}));
}
remaining = remaining.saturating_sub(bsize.max(1));
}
Ok(ExtentStream::new(runs.into_iter()))
}
fn lookup(&self, parent: FileId, name: &[u8]) -> VfsResult<Option<FileId>> {
let dir_ino = ino_of(parent)?;
let entries = list_dir(&self.image, &self.sb, dir_ino).map_err(map_err)?;
Ok(entries
.into_iter()
.find(|e| e.name == name)
.map(|e| FileId::Opaque(e.ino)))
}
fn meta(&self, ino: FileId) -> VfsResult<FsMeta> {
let inode_no = ino_of(ino)?;
let inode = read_inode(&self.image, &self.sb, inode_no).map_err(map_err)?;
let residency = match inode.symlink_target() {
Some(t) => ResidencyKind::Resident {
inline_len: u32::try_from(t.len()).unwrap_or(u32::MAX),
},
None => ResidencyKind::NonResident,
};
Ok(FsMeta {
ino: inode_no,
kind: node_kind(inode.file_type),
allocated: Allocation::Allocated,
size: inode.size,
nlink: u32::from(inode.nlink),
uid: Some(inode.uid),
gid: Some(inode.gid),
mode: Some(u32::from(inode.mode)),
times: MacbTimes {
modified: Some(to_ts(inode.mtime)),
accessed: Some(to_ts(inode.atime)),
changed: Some(to_ts(inode.ctime)),
born: inode.birthtime.map(to_ts),
},
streams: Vec::new(),
residency,
link_target: None,
})
}
fn read_at(&self, ino: FileId, stream: StreamId, off: u64, buf: &mut [u8]) -> VfsResult<usize> {
require_default_stream(stream)?;
let inode_no = ino_of(ino)?;
let file = read_file(&self.image, &self.sb, inode_no).map_err(map_err)?;
let start = usize::try_from(off).unwrap_or(usize::MAX);
let Some(slice) = file.get(start..) else {
return Ok(0);
};
let n = slice.len().min(buf.len());
buf[..n].copy_from_slice(&slice[..n]);
Ok(n)
}
fn read_link(&self, ino: FileId, cap: usize) -> VfsResult<Vec<u8>> {
let inode = self.inode(ino)?;
if inode.file_type != FileType::Symlink {
return Ok(Vec::new());
}
let mut target = read_symlink_target(&self.image, &self.sb, &inode).map_err(map_err)?;
target.truncate(cap);
Ok(target)
}
fn deleted(&self) -> VfsResult<NodeStream> {
Ok(NodeStream::empty())
}
fn unallocated(&self) -> VfsResult<ExtentStream> {
Ok(ExtentStream::empty())
}
}
impl UfsFs {
fn entry_kind(&self, ino: u64) -> NodeKind {
read_inode(&self.image, &self.sb, ino).map_or(NodeKind::Other, |i| node_kind(i.file_type))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::inode::UFS2_DINODE_SIZE;
use crate::superblock::FS_UFS2_MAGIC;
use std::sync::Arc as StdArc;
struct Bytes(Vec<u8>);
impl forensic_vfs::ImageSource for Bytes {
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)
}
}
#[test]
fn node_kind_maps_every_ifmt_type() {
assert_eq!(node_kind(FileType::Regular), NodeKind::File);
assert_eq!(node_kind(FileType::Directory), NodeKind::Dir);
assert_eq!(node_kind(FileType::Symlink), NodeKind::Symlink);
assert_eq!(node_kind(FileType::CharDevice), NodeKind::Device);
assert_eq!(node_kind(FileType::BlockDevice), NodeKind::Device);
assert_eq!(node_kind(FileType::Fifo), NodeKind::Other);
assert_eq!(node_kind(FileType::Socket), NodeKind::Other);
assert_eq!(node_kind(FileType::Whiteout), NodeKind::Other);
assert_eq!(node_kind(FileType::Other(0o050_000)), NodeKind::Other);
}
#[test]
fn to_ts_carries_ns_and_inode_table_provenance() {
let ts = to_ts(Timespec { sec: 5, nsec: 123 });
assert_eq!(ts.unix_nanos, 5 * 1_000_000_000 + 123);
assert_eq!(ts.source, TimeSource::InodeTable);
assert_eq!(ts.resolution, TimeResolution::Nanos);
assert_eq!(
to_ts(Timespec { sec: -1, nsec: 0 }).unix_nanos,
-1_000_000_000
);
}
#[test]
fn map_err_splits_truncated_from_decode() {
let oor = map_err(UfsError::Truncated {
structure: "x",
need: 9,
have: 4,
});
assert!(matches!(
oor,
VfsError::OutOfRange {
offset: 9,
bound: 4,
..
}
));
let dec = map_err(UfsError::InodeOutOfRange { ino: 1, count: 1 });
assert!(matches!(dec, VfsError::Decode { layer: "ufs", .. }));
}
#[test]
fn require_default_stream_refuses_named_streams() {
assert!(require_default_stream(StreamId::Default).is_ok());
assert!(matches!(
require_default_stream(StreamId::Slack),
Err(VfsError::Unsupported {
layer: "ufs stream",
..
})
));
}
#[test]
fn ino_of_refuses_foreign_identity() {
assert_eq!(ino_of(FileId::Opaque(42)).unwrap(), 42);
assert!(matches!(
ino_of(FileId::NtfsRef { entry: 1, seq: 1 }),
Err(VfsError::Unsupported {
layer: "ufs file-id",
..
})
));
}
#[test]
fn ufs_probe_matches_ufs2_magic_either_order() {
let mut le = vec![0u8; UFS2_MAGIC_OFF + 4];
le[UFS2_MAGIC_OFF..UFS2_MAGIC_OFF + 4].copy_from_slice(UFS2_MAGIC_LE);
assert!(matches!(
ufs_probe(&SniffWindow::new(0, &le)),
Confidence::Yes { .. }
));
let mut be = vec![0u8; UFS2_MAGIC_OFF + 4];
be[UFS2_MAGIC_OFF..UFS2_MAGIC_OFF + 4].copy_from_slice(UFS2_MAGIC_BE);
assert!(matches!(
ufs_probe(&SniffWindow::new(0, &be)),
Confidence::Yes { .. }
));
}
#[test]
fn ufs_probe_matches_ufs1_magic_either_order() {
let mut le = vec![0u8; UFS1_MAGIC_OFF + 4];
le[UFS1_MAGIC_OFF..UFS1_MAGIC_OFF + 4].copy_from_slice(UFS1_MAGIC_LE);
assert!(matches!(
ufs_probe(&SniffWindow::new(0, &le)),
Confidence::Yes { .. }
));
let mut be = vec![0u8; UFS1_MAGIC_OFF + 4];
be[UFS1_MAGIC_OFF..UFS1_MAGIC_OFF + 4].copy_from_slice(UFS1_MAGIC_BE);
assert!(matches!(
ufs_probe(&SniffWindow::new(0, &be)),
Confidence::Yes { .. }
));
}
#[test]
fn ufs_probe_declines_non_ufs() {
assert_eq!(ufs_probe(&SniffWindow::new(0, b"not ufs")), Confidence::No);
assert_eq!(ufs_probe(&SniffWindow::new(0, &[])), Confidence::No);
}
const FSIZE: usize = 4096;
const IBLKNO: usize = 40;
const FPG: usize = 256;
const IPG: usize = 128;
const ISZ: usize = UFS2_DINODE_SIZE;
const BSIZE: usize = 32768;
fn ino_byte(ino: usize) -> usize {
let c = ino / IPG;
let within = ino % IPG;
(c * FPG + IBLKNO) * FSIZE + within * ISZ
}
fn dinode(mode: u16, size: u64, direct: &[u64]) -> Vec<u8> {
let mut d = vec![0u8; ISZ];
d[0..2].copy_from_slice(&mode.to_le_bytes()); d[2..4].copy_from_slice(&1u16.to_le_bytes()); d[4..8].copy_from_slice(&1000u32.to_le_bytes()); d[8..12].copy_from_slice(&1000u32.to_le_bytes()); d[16..24].copy_from_slice(&size.to_le_bytes()); d[40..48].copy_from_slice(&0x1122_3344i64.to_le_bytes()); d[64..68].copy_from_slice(&500i32.to_le_bytes()); d[56..64].copy_from_slice(&0x2233i64.to_le_bytes()); for (i, &a) in direct.iter().enumerate().take(UFS_NDADDR) {
d[112 + i * 8..112 + i * 8 + 8].copy_from_slice(&a.to_le_bytes());
}
d
}
fn symlink_dinode(target: &[u8]) -> Vec<u8> {
let mut d = vec![0u8; ISZ];
d[0..2].copy_from_slice(&0o120_777u16.to_le_bytes()); d[2..4].copy_from_slice(&1u16.to_le_bytes());
d[16..24].copy_from_slice(&(target.len() as u64).to_le_bytes()); d[112..112 + target.len()].copy_from_slice(target); d
}
fn direct(ino: u32, reclen: u16, d_type: u8, name: &[u8]) -> Vec<u8> {
let mut e = vec![0u8; reclen as usize];
e[0..4].copy_from_slice(&ino.to_le_bytes());
e[4..6].copy_from_slice(&reclen.to_le_bytes());
e[6] = d_type;
e[7] = name.len() as u8;
e[8..8 + name.len()].copy_from_slice(name);
e
}
fn write_superblock(part: &mut [u8]) {
let mut d = vec![0u8; 1376];
let wr32 = |d: &mut [u8], off: usize, v: i32| {
d[off..off + 4].copy_from_slice(&v.to_le_bytes());
};
let wr64 = |d: &mut [u8], off: usize, v: i64| {
d[off..off + 8].copy_from_slice(&v.to_le_bytes());
};
wr32(&mut d, 8, 24); wr32(&mut d, 12, 32); wr32(&mut d, 16, IBLKNO as i32); wr32(&mut d, 20, 48); wr32(&mut d, 44, 4); wr32(&mut d, 48, BSIZE as i32); wr32(&mut d, 52, FSIZE as i32); wr32(&mut d, 56, 8); wr32(&mut d, 80, 15); wr32(&mut d, 84, 12); wr32(&mut d, 116, 2048); wr32(&mut d, 120, 128); wr32(&mut d, 184, IPG as i32); wr32(&mut d, 188, FPG as i32); wr32(&mut d, 1320, 120); wr64(&mut d, 1080, 1022); wr64(&mut d, 1088, 901); wr64(&mut d, 1000, SBLOCK_UFS2 as i64); d[1372..1376].copy_from_slice(&FS_UFS2_MAGIC.to_le_bytes());
part[SBLOCK_UFS2..SBLOCK_UFS2 + 1376].copy_from_slice(&d);
}
fn image_with_tree() -> Vec<u8> {
let root_frag = 60u64;
let file_frag = 61u64;
let sub_frag = 62u64;
let content = b"content-1\n";
let max = [
SBLOCK_UFS2 + 1376,
ino_byte(7) + ISZ,
(sub_frag as usize + 1) * FSIZE,
]
.into_iter()
.max()
.unwrap();
let mut part = vec![0u8; max + 16];
write_superblock(&mut part);
let root = dinode(0o040_755, 512, &[root_frag]);
part[ino_byte(2)..ino_byte(2) + ISZ].copy_from_slice(&root);
let file = dinode(0o100_644, content.len() as u64, &[file_frag]);
part[ino_byte(4)..ino_byte(4) + ISZ].copy_from_slice(&file);
let sym = symlink_dinode(b"target/path");
part[ino_byte(5)..ino_byte(5) + ISZ].copy_from_slice(&sym);
let sub = dinode(0o040_755, 512, &[sub_frag]);
part[ino_byte(6)..ino_byte(6) + ISZ].copy_from_slice(&sub);
let mut rb = Vec::new();
rb.extend(direct(2, 12, 4, b"."));
rb.extend(direct(2, 12, 4, b".."));
rb.extend(direct(4, 24, 8, b"file.txt"));
rb.extend(direct(5, 16, 10, b"sym"));
rb.extend(direct(6, DIRBLKSIZ as u16 - 64, 4, b"sub"));
assert_eq!(rb.len(), DIRBLKSIZ, "root block is one DIRBLKSIZ");
let rbo = root_frag as usize * FSIZE;
part[rbo..rbo + rb.len()].copy_from_slice(&rb);
let fbo = file_frag as usize * FSIZE;
part[fbo..fbo + content.len()].copy_from_slice(content);
let mut sbk = Vec::new();
sbk.extend(direct(6, 12, 4, b"."));
sbk.extend(direct(2, DIRBLKSIZ as u16 - 12, 4, b".."));
let sbo = sub_frag as usize * FSIZE;
part[sbo..sbo + sbk.len()].copy_from_slice(&sbk);
part
}
use crate::dir::DIRBLKSIZ;
fn mount(image: Vec<u8>) -> UfsFs {
UfsFs::open(&(StdArc::new(Bytes(image)) as DynSource)).unwrap()
}
#[test]
fn open_rejects_non_ufs_source_loud() {
let bad = vec![0u8; SBLOCK_UFS2 + 2000];
let opened = UfsFs::open(&(StdArc::new(Bytes(bad)) as DynSource));
assert!(
matches!(opened, Err(VfsError::Decode { layer: "ufs", .. })),
"a non-UFS source must fail loud with a UFS Decode error"
);
}
#[test]
fn adapter_geometry_surface() {
let fs = mount(image_with_tree());
let vfs: &dyn FileSystem = &fs;
assert_eq!(vfs.kind(), FsKind::UFS);
assert_eq!(vfs.timestamp_zone(), TimeZonePolicy::Utc);
let sizes = vfs.sector_sizes();
assert_eq!(sizes.logical, 512);
assert_eq!(sizes.physical, 512);
assert_eq!(sizes.cluster_or_block, BSIZE as u32);
assert_eq!(vfs.root(), FileId::Opaque(UFS_ROOTINO));
}
#[test]
fn adapter_navigates_the_synthetic_tree() {
let fs = mount(image_with_tree());
let vfs: &dyn FileSystem = &fs;
let root = vfs.root();
assert_eq!(vfs.meta(root).unwrap().kind, NodeKind::Dir);
let listing: Vec<(String, FileId, NodeKind)> = vfs
.read_dir(root)
.unwrap()
.filter_map(Result::ok)
.map(|e| (String::from_utf8_lossy(&e.name).into_owned(), e.id, e.kind))
.collect();
assert!(listing.contains(&("file.txt".to_string(), FileId::Opaque(4), NodeKind::File)));
assert!(listing.contains(&("sym".to_string(), FileId::Opaque(5), NodeKind::Symlink)));
assert!(listing.contains(&("sub".to_string(), FileId::Opaque(6), NodeKind::Dir)));
let file = vfs.lookup(root, b"file.txt").unwrap().expect("file.txt");
assert_eq!(file, FileId::Opaque(4));
assert!(vfs.lookup(root, b"nope").unwrap().is_none());
let meta = vfs.meta(file).unwrap();
assert_eq!(meta.kind, NodeKind::File);
assert_eq!(meta.size, 10);
assert_eq!(meta.residency, ResidencyKind::NonResident);
assert_eq!(meta.uid, Some(1000));
assert_eq!(meta.gid, Some(1000));
assert_eq!(meta.nlink, 1);
assert!(meta.times.modified.is_some());
assert!(meta.times.born.is_some());
let mut buf = vec![0u8; meta.size as usize];
let n = vfs.read_at(file, StreamId::Default, 0, &mut buf).unwrap();
buf.truncate(n);
assert_eq!(buf, b"content-1\n");
assert_eq!(
vfs.read_at(file, StreamId::Default, meta.size + 100, &mut [0u8; 4])
.unwrap(),
0
);
let runs: Vec<_> = vfs
.extents(file, StreamId::Default)
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].run.image_offset, 61 * FSIZE as u64);
assert_eq!(runs[0].run.len, 10);
assert_eq!(runs[0].alloc, RunAlloc::Allocated);
assert!(vfs.extents(file, StreamId::Named(1)).is_err());
assert!(vfs
.read_at(file, StreamId::Slack, 0, &mut [0u8; 4])
.is_err());
let sub = vfs.lookup(root, b"sub").unwrap().expect("sub");
assert_eq!(vfs.meta(sub).unwrap().kind, NodeKind::Dir);
let sub_children: Vec<String> = vfs
.read_dir(sub)
.unwrap()
.filter_map(Result::ok)
.map(|e| String::from_utf8_lossy(&e.name).into_owned())
.collect();
assert!(sub_children.contains(&".".to_string()));
assert!(sub_children.contains(&"..".to_string()));
let sym = vfs.lookup(root, b"sym").unwrap().expect("sym");
assert_eq!(vfs.meta(sym).unwrap().kind, NodeKind::Symlink);
assert!(matches!(
vfs.meta(sym).unwrap().residency,
ResidencyKind::Resident { .. }
));
assert_eq!(vfs.read_link(sym, 4096).unwrap(), b"target/path");
assert_eq!(vfs.read_link(sym, 6).unwrap(), b"target");
assert_eq!(vfs.read_link(root, 4096).unwrap(), Vec::<u8>::new());
assert_eq!(vfs.deleted().unwrap().count(), 0);
assert_eq!(vfs.unallocated().unwrap().count(), 0);
}
#[test]
fn extents_on_empty_file_is_empty() {
let mut part = image_with_tree();
let empty = dinode(0o100_644, 0, &[]);
part[ino_byte(4)..ino_byte(4) + ISZ].copy_from_slice(&empty);
let fs = mount(part);
let vfs: &dyn FileSystem = &fs;
assert_eq!(
vfs.extents(FileId::Opaque(4), StreamId::Default)
.unwrap()
.count(),
0
);
}
}