use forensic_vfs::{
Allocation, DirEntry as VfsDirEntry, DirStream, ExtentStream, FileId, FileSystem, FsKind,
FsMeta, ImageSource, MacbTimes, NodeKind, NodeStream, ResidencyKind, SectorSizes, StreamId,
TimeResolution, TimeSource, TimeStamp, TimeZonePolicy, VfsError, VfsResult,
};
use crate::{
fs_tree_root, list_dir, read_file, read_inode, read_node, ChunkMap, DirItemType, Inode, Node,
Superblock, Timestamp, BTRFS_SUPER_INFO_OFFSET,
};
pub struct BtrfsFs {
image: Vec<u8>,
sb: Superblock,
map: ChunkMap,
leaf: Node,
root_dirid: u64,
}
fn oid_of(id: FileId) -> VfsResult<u64> {
match id {
FileId::Opaque(oid) => Ok(oid),
other => Err(VfsError::Unsupported {
layer: "btrfs file-id",
scheme: format!("{other:?}"),
}),
}
}
fn require_default_stream(stream: StreamId) -> VfsResult<()> {
match stream {
StreamId::Default => Ok(()),
other => Err(VfsError::Unsupported {
layer: "btrfs stream",
scheme: format!("{other:?}"),
}),
}
}
fn fill(src: &dyn ImageSource, mut off: u64, mut buf: &mut [u8]) -> VfsResult<()> {
while !buf.is_empty() {
let n = src.read_at(off, buf)?;
if n == 0 {
break;
}
off = off.saturating_add(n as u64);
let Some(rest) = buf.get_mut(n..) else {
break; };
buf = rest;
}
Ok(())
}
fn dirent_kind(t: &DirItemType) -> NodeKind {
match t {
DirItemType::File => NodeKind::File,
DirItemType::Dir => NodeKind::Dir,
DirItemType::Symlink => NodeKind::Symlink,
DirItemType::Other(_) => NodeKind::Other,
}
}
fn inode_kind(inode: &Inode) -> NodeKind {
match inode.mode & 0o170000 {
0o100000 => NodeKind::File,
0o040000 => NodeKind::Dir,
0o120000 => NodeKind::Symlink,
0o020000 | 0o060000 => NodeKind::Device,
_ => NodeKind::Other,
}
}
fn stamp(t: &Timestamp) -> TimeStamp {
TimeStamp {
unix_nanos: i128::from(t.sec) * 1_000_000_000 + i128::from(t.nsec),
source: TimeSource::InodeTable,
resolution: TimeResolution::Nanos,
}
}
impl BtrfsFs {
pub fn open(source: &dyn ImageSource) -> VfsResult<Self> {
let len = source.len();
let mut image = vec![0u8; len as usize];
fill(source, 0, &mut image)?;
let sb_slice =
image
.get(BTRFS_SUPER_INFO_OFFSET as usize..)
.ok_or(VfsError::OutOfRange {
what: "btrfs superblock offset",
offset: BTRFS_SUPER_INFO_OFFSET,
len: 1,
bound: len,
})?;
let sb = Superblock::parse(sb_slice).map_err(map_btrfs_err)?;
let map = ChunkMap::walk(&image, &sb).map_err(map_btrfs_err)?;
let root = fs_tree_root(&image, &sb, &map).map_err(map_btrfs_err)?;
let leaf = read_node(&image, &sb, &map, root.bytenr).map_err(map_btrfs_err)?;
Ok(Self {
image,
sb,
map,
leaf,
root_dirid: root.root_dirid,
})
}
}
fn map_btrfs_err(e: crate::BtrfsError) -> VfsError {
VfsError::Decode {
layer: "btrfs",
offset: 0,
detail: e.to_string(),
bytes: forensic_vfs::SmallHex::new(&[]),
}
}
impl FileSystem for BtrfsFs {
fn kind(&self) -> FsKind {
FsKind::BTRFS
}
fn root(&self) -> FileId {
FileId::Opaque(self.root_dirid)
}
fn sector_sizes(&self) -> SectorSizes {
SectorSizes {
logical: 512,
physical: 512,
cluster_or_block: self.sb.sectorsize,
}
}
fn timestamp_zone(&self) -> TimeZonePolicy {
TimeZonePolicy::Utc
}
fn read_dir(&self, ino: FileId) -> VfsResult<DirStream> {
let dir_oid = oid_of(ino)?;
let out: Vec<VfsResult<VfsDirEntry>> = list_dir(&self.leaf, dir_oid)
.into_iter()
.map(|e| {
Ok(VfsDirEntry {
name: e.name.into_bytes(),
id: FileId::Opaque(e.child),
kind: dirent_kind(&e.item_type),
})
})
.collect();
Ok(DirStream::new(out.into_iter()))
}
fn extents(&self, _ino: FileId, _stream: StreamId) -> VfsResult<ExtentStream> {
Ok(ExtentStream::empty())
}
fn lookup(&self, parent: FileId, name: &[u8]) -> VfsResult<Option<FileId>> {
let dir_oid = oid_of(parent)?;
let found = list_dir(&self.leaf, dir_oid)
.into_iter()
.find(|e| e.name.as_bytes() == name)
.map(|e| FileId::Opaque(e.child));
Ok(found)
}
fn meta(&self, ino: FileId) -> VfsResult<FsMeta> {
let oid = oid_of(ino)?;
let inode = read_inode(&self.leaf, oid).ok_or(VfsError::OutOfRange {
what: "btrfs inode (not in FS-tree root leaf)",
offset: oid,
len: 1,
bound: 0,
})?;
let born = if inode.otime.sec != 0 {
Some(stamp(&inode.otime))
} else {
None
};
Ok(FsMeta {
ino: inode.objectid,
kind: inode_kind(&inode),
allocated: Allocation::Allocated,
size: inode.size,
nlink: inode.nlink,
uid: Some(inode.uid),
gid: Some(inode.gid),
mode: Some(inode.mode),
times: MacbTimes {
modified: Some(stamp(&inode.mtime)),
accessed: Some(stamp(&inode.atime)),
changed: Some(stamp(&inode.ctime)),
born,
},
streams: Vec::new(),
residency: ResidencyKind::NonResident,
link_target: None,
})
}
fn read_at(&self, ino: FileId, stream: StreamId, off: u64, buf: &mut [u8]) -> VfsResult<usize> {
let oid = oid_of(ino)?;
require_default_stream(stream)?;
let data = read_file(&self.image, &self.sb, &self.map, oid).map_err(map_btrfs_err)?;
let start = off.min(data.len() as u64) as usize;
let Some(avail) = data.get(start..) else {
return Ok(0); };
let n = avail.len().min(buf.len());
let Some(dst) = buf.get_mut(..n) else {
return Ok(0); };
dst.copy_from_slice(&avail[..n]);
Ok(n)
}
fn read_link(&self, ino: FileId, cap: usize) -> VfsResult<Vec<u8>> {
let oid = oid_of(ino)?;
let Some(inode) = read_inode(&self.leaf, oid) else {
return Ok(Vec::new());
};
if inode_kind(&inode) != NodeKind::Symlink {
return Ok(Vec::new());
}
let mut target = read_file(&self.image, &self.sb, &self.map, oid).map_err(map_btrfs_err)?;
target.truncate(cap);
Ok(target)
}
fn deleted(&self) -> VfsResult<NodeStream> {
Ok(NodeStream::empty())
}
fn unallocated(&self) -> VfsResult<ExtentStream> {
Ok(ExtentStream::empty())
}
}
#[cfg(test)]
mod tests {
use super::BtrfsFs;
use forensic_vfs::{
FileId, FileSystem, FsKind, ImageSource, NodeKind, StreamId, VfsError, VfsResult,
};
const NODESIZE: usize = 16_384;
const HDR_END: usize = 101;
const ITEM_STRIDE: usize = 25;
const SUPER_OFFSET: usize = 65_536;
const SUPER_SIZE: usize = 4096;
const ROOT_LOGICAL: u64 = 0x20_000;
const FS_LEAF_LOGICAL: u64 = 0x30_000;
const CHUNK_LEN: u64 = 4 * 1024 * 1024;
struct MemSource(Vec<u8>);
impl ImageSource for MemSource {
fn len(&self) -> u64 {
self.0.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize> {
let start = (offset as usize).min(self.0.len());
let avail = &self.0[start..];
let n = avail.len().min(buf.len());
buf[..n].copy_from_slice(&avail[..n]);
Ok(n)
}
}
fn crc32c(buf: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in buf {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 == 1 {
(crc >> 1) ^ 0x82F6_3B78
} else {
crc >> 1
};
}
}
crc ^ 0xFFFF_FFFF
}
fn build_leaf(owner: u64, items: &[(u64, u8, u64, Vec<u8>)]) -> Vec<u8> {
let mut node = vec![0u8; NODESIZE];
node[0x30..0x38].copy_from_slice(&30_654_464u64.to_le_bytes()); node[0x58..0x60].copy_from_slice(&owner.to_le_bytes());
node[0x60..0x64].copy_from_slice(&(items.len() as u32).to_le_bytes());
node[0x64] = 0; let mut tail = NODESIZE;
for (i, (oid, ty, koff, data)) in items.iter().enumerate() {
let io = HDR_END + i * ITEM_STRIDE;
node[io..io + 8].copy_from_slice(&oid.to_le_bytes());
node[io + 8] = *ty;
node[io + 9..io + 17].copy_from_slice(&koff.to_le_bytes());
tail -= data.len();
let doff = (tail - HDR_END) as u32;
node[io + 17..io + 21].copy_from_slice(&doff.to_le_bytes());
node[io + 21..io + 25].copy_from_slice(&(data.len() as u32).to_le_bytes());
node[tail..tail + data.len()].copy_from_slice(data);
}
let c = crc32c(&node[0x20..]);
node[0..4].copy_from_slice(&c.to_le_bytes());
node
}
#[allow(clippy::too_many_arguments)]
fn inode_item(size: u64, mode: u32, nlink: u32, uid: u32, gid: u32, otime_sec: u64) -> Vec<u8> {
let mut d = vec![0u8; 160];
d[16..24].copy_from_slice(&size.to_le_bytes());
d[40..44].copy_from_slice(&nlink.to_le_bytes());
d[44..48].copy_from_slice(&uid.to_le_bytes());
d[48..52].copy_from_slice(&gid.to_le_bytes());
d[52..56].copy_from_slice(&mode.to_le_bytes());
d[112..120].copy_from_slice(&111u64.to_le_bytes()); d[124..132].copy_from_slice(&222u64.to_le_bytes()); d[136..144].copy_from_slice(&333u64.to_le_bytes()); d[148..156].copy_from_slice(&otime_sec.to_le_bytes()); d
}
fn inline_extent(payload: &[u8]) -> Vec<u8> {
let mut d = vec![0u8; 21 + payload.len()];
d[8..16].copy_from_slice(&(payload.len() as u64).to_le_bytes());
d[20] = 0; d[21..].copy_from_slice(payload);
d
}
fn dir_item(child: u64, ft: u8, name: &[u8]) -> Vec<u8> {
let mut d = vec![0u8; 30 + name.len()];
d[0..8].copy_from_slice(&child.to_le_bytes());
d[8] = 1; d[27..29].copy_from_slice(&(name.len() as u16).to_le_bytes());
d[29] = ft;
d[30..].copy_from_slice(name);
d
}
fn build_chunk_leaf() -> Vec<u8> {
let mut node = vec![0u8; NODESIZE];
node[0x30..0x38].copy_from_slice(&0u64.to_le_bytes()); node[0x58..0x60].copy_from_slice(&3u64.to_le_bytes()); node[0x60..0x64].copy_from_slice(&1u32.to_le_bytes()); node[0x64] = 0; let mut chunk = vec![0u8; 48 + 32];
chunk[0..8].copy_from_slice(&CHUNK_LEN.to_le_bytes()); chunk[24..32].copy_from_slice(&0x1u64.to_le_bytes()); chunk[44..46].copy_from_slice(&1u16.to_le_bytes()); chunk[46..48].copy_from_slice(&1u16.to_le_bytes()); chunk[48..56].copy_from_slice(&1u64.to_le_bytes()); chunk[56..64].copy_from_slice(&0u64.to_le_bytes()); let data_tail = NODESIZE - chunk.len();
let io = HDR_END;
node[io..io + 8].copy_from_slice(&256u64.to_le_bytes()); node[io + 8] = 228; node[io + 9..io + 17].copy_from_slice(&0u64.to_le_bytes()); node[io + 17..io + 21].copy_from_slice(&((data_tail - HDR_END) as u32).to_le_bytes());
node[io + 21..io + 25].copy_from_slice(&(chunk.len() as u32).to_le_bytes());
node[data_tail..data_tail + chunk.len()].copy_from_slice(&chunk);
let c = crc32c(&node[0x20..]);
node[0..4].copy_from_slice(&c.to_le_bytes());
node
}
fn build_super() -> Vec<u8> {
let mut sb = vec![0u8; SUPER_SIZE];
sb[0x40..0x48].copy_from_slice(b"_BHRfS_M");
sb[0x30..0x38].copy_from_slice(&65536u64.to_le_bytes()); sb[0x50..0x58].copy_from_slice(&ROOT_LOGICAL.to_le_bytes()); sb[0x58..0x60].copy_from_slice(&0u64.to_le_bytes()); sb[0x90..0x94].copy_from_slice(&4096u32.to_le_bytes()); sb[0x94..0x98].copy_from_slice(&(NODESIZE as u32).to_le_bytes()); let arr = 0x32busize;
sb[arr..arr + 8].copy_from_slice(&256u64.to_le_bytes());
sb[arr + 8] = 228;
sb[arr + 9..arr + 17].copy_from_slice(&0u64.to_le_bytes()); let mut ci = vec![0u8; 48 + 32];
ci[0..8].copy_from_slice(&CHUNK_LEN.to_le_bytes());
ci[24..32].copy_from_slice(&0x2u64.to_le_bytes()); ci[44..46].copy_from_slice(&1u16.to_le_bytes());
ci[46..48].copy_from_slice(&1u16.to_le_bytes());
ci[48..56].copy_from_slice(&1u64.to_le_bytes());
ci[56..64].copy_from_slice(&0u64.to_le_bytes());
sb[arr + 17..arr + 17 + ci.len()].copy_from_slice(&ci);
sb[0xa0..0xa4].copy_from_slice(&((17 + ci.len()) as u32).to_le_bytes());
sb
}
fn walkable_image() -> Vec<u8> {
let mut img = vec![0u8; CHUNK_LEN as usize];
img[0..NODESIZE].copy_from_slice(&build_chunk_leaf());
img[SUPER_OFFSET..SUPER_OFFSET + SUPER_SIZE].copy_from_slice(&build_super());
let mut root_item = vec![0u8; 239];
root_item[168..176].copy_from_slice(&256u64.to_le_bytes()); root_item[176..184].copy_from_slice(&FS_LEAF_LOGICAL.to_le_bytes()); let root_leaf = build_leaf(
1,
&[(5, 132 , 0, root_item)],
);
img[ROOT_LOGICAL as usize..ROOT_LOGICAL as usize + NODESIZE].copy_from_slice(&root_leaf);
let link_target = b"target/path";
let fs_leaf = build_leaf(
5,
&[
(256, 1, 0, inode_item(0, 0o040_755, 2, 0, 0, 500)),
(256, 84, 10, dir_item(257, 1 , b"note.txt")),
(256, 84, 20, dir_item(258, 7 , b"link")),
(256, 84, 30, dir_item(259, 2 , b"sub")),
(257, 1, 0, inode_item(9, 0o100_644, 1, 1000, 1000, 700)),
(257, 108, 0, inline_extent(b"note body")),
(
258,
1,
0,
inode_item(link_target.len() as u64, 0o120_777, 1, 0, 0, 0),
),
(258, 108, 0, inline_extent(link_target)),
(259, 1, 0, inode_item(0, 0o040_755, 2, 0, 0, 0)),
(256, 84, 40, dir_item(260, 99 , b"dev")),
(260, 1, 0, inode_item(0, 0o020_000, 1, 0, 0, 0)),
(261, 1, 0, inode_item(0, 0o010_000, 1, 0, 0, 0)),
],
);
img[FS_LEAF_LOGICAL as usize..FS_LEAF_LOGICAL as usize + fs_leaf.len()]
.copy_from_slice(&fs_leaf);
img
}
fn open_crafted() -> BtrfsFs {
BtrfsFs::open(&MemSource(walkable_image())).expect("open crafted walkable btrfs image")
}
#[test]
fn open_and_root_metadata_over_crafted_image() {
let fs = open_crafted();
assert_eq!(fs.kind(), FsKind::BTRFS);
assert_eq!(fs.root(), FileId::Opaque(256));
let ss = fs.sector_sizes();
assert_eq!(ss.logical, 512);
assert_eq!(ss.physical, 512);
assert_eq!(ss.cluster_or_block, 4096, "btrfs sectorsize");
assert!(matches!(
fs.timestamp_zone(),
forensic_vfs::TimeZonePolicy::Utc
));
assert!(fs.extents(FileId::Opaque(257), StreamId::Default).is_ok());
assert!(fs.deleted().is_ok());
assert!(fs.unallocated().is_ok());
}
#[test]
fn read_dir_and_lookup_classify_children() {
let fs = open_crafted();
let entries: Vec<_> = fs
.read_dir(FileId::Opaque(256))
.expect("read_dir root")
.collect::<Result<_, _>>()
.expect("dir entries");
let note = entries.iter().find(|e| e.name == b"note.txt").unwrap();
assert_eq!(note.id, FileId::Opaque(257));
assert_eq!(note.kind, NodeKind::File);
let link = entries.iter().find(|e| e.name == b"link").unwrap();
assert_eq!(link.kind, NodeKind::Symlink);
let sub = entries.iter().find(|e| e.name == b"sub").unwrap();
assert_eq!(sub.kind, NodeKind::Dir);
let dev = entries.iter().find(|e| e.name == b"dev").unwrap();
assert_eq!(dev.kind, NodeKind::Other, "unknown FT type → Other");
assert_eq!(
fs.lookup(FileId::Opaque(256), b"note.txt").unwrap(),
Some(FileId::Opaque(257))
);
assert_eq!(fs.lookup(FileId::Opaque(256), b"absent").unwrap(), None);
}
#[test]
fn meta_classifies_device_and_other_inode_kinds() {
let fs = open_crafted();
assert_eq!(fs.meta(FileId::Opaque(260)).unwrap().kind, NodeKind::Device);
assert_eq!(fs.meta(FileId::Opaque(261)).unwrap().kind, NodeKind::Other);
}
#[test]
fn open_tolerates_a_source_that_short_reads_before_eof() {
struct ShortSource {
data: Vec<u8>,
reported_len: u64,
}
impl ImageSource for ShortSource {
fn len(&self) -> u64 {
self.reported_len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize> {
let start = offset as usize;
if start >= self.data.len() {
return Ok(0); }
let avail = &self.data[start..];
let n = avail.len().min(buf.len());
buf[..n].copy_from_slice(&avail[..n]);
Ok(n)
}
}
let data = walkable_image();
let reported_len = data.len() as u64 + 4096; let fs = BtrfsFs::open(&ShortSource { data, reported_len })
.expect("open despite the trailing short read");
assert_eq!(fs.root(), FileId::Opaque(256));
}
#[test]
fn meta_decodes_inode_kind_size_and_times() {
let fs = open_crafted();
let m = fs.meta(FileId::Opaque(257)).unwrap();
assert_eq!(m.ino, 257);
assert_eq!(m.kind, NodeKind::File);
assert_eq!(m.size, 9);
assert_eq!(m.nlink, 1);
assert_eq!(m.uid, Some(1000));
assert_eq!(m.gid, Some(1000));
assert_eq!(m.mode, Some(0o100_644));
assert!(m.times.born.is_some(), "otime != 0 → born present");
assert!(m.times.modified.is_some());
let d = fs.meta(FileId::Opaque(259)).unwrap();
assert_eq!(d.kind, NodeKind::Dir);
assert!(d.times.born.is_none(), "otime == 0 → born absent");
assert!(matches!(
fs.meta(FileId::Opaque(9999)),
Err(VfsError::OutOfRange { .. })
));
}
#[test]
fn read_at_windows_the_file_content() {
let fs = open_crafted();
let mut buf = vec![0u8; 4];
let n = fs
.read_at(FileId::Opaque(257), StreamId::Default, 5, &mut buf)
.unwrap();
assert_eq!(&buf[..n], b"body", "read_at windows from offset 5");
let n = fs
.read_at(FileId::Opaque(257), StreamId::Default, 1000, &mut buf)
.unwrap();
assert_eq!(n, 0);
}
#[test]
fn read_link_returns_symlink_target_and_empty_for_non_symlink() {
let fs = open_crafted();
let target = fs.read_link(FileId::Opaque(258), 4096).unwrap();
assert_eq!(target, b"target/path", "symlink target = inline content");
let capped = fs.read_link(FileId::Opaque(258), 6).unwrap();
assert_eq!(capped, b"target");
assert!(fs.read_link(FileId::Opaque(257), 4096).unwrap().is_empty());
assert!(fs.read_link(FileId::Opaque(9999), 4096).unwrap().is_empty());
}
#[test]
fn non_opaque_file_id_and_named_stream_are_refused_loud() {
let fs = open_crafted();
assert!(matches!(
fs.read_dir(FileId::NtfsRef { entry: 1, seq: 0 }),
Err(VfsError::Unsupported { .. })
));
let mut buf = [0u8; 4];
assert!(matches!(
fs.read_at(FileId::Opaque(257), StreamId::Named(7), 0, &mut buf),
Err(VfsError::Unsupported { .. })
));
}
#[test]
fn open_malformed_source_is_a_loud_error_not_a_panic() {
assert!(BtrfsFs::open(&MemSource(vec![0u8; 16])).is_err());
assert!(BtrfsFs::open(&MemSource(vec![0u8; SUPER_OFFSET + SUPER_SIZE])).is_err());
}
}