use std::io::{Read, Seek, SeekFrom};
use crate::tree::TreeNode;
const NXSB_MAGIC: u32 = 0x4253_584e;
const APSB_MAGIC: u32 = 0x4253_5041;
const NXSB_MAGIC_OFFSET: u64 = 32;
const NXSB_BLOCK_SIZE_OFFSET: u64 = 36;
const NXSB_FS_OID_OFFSET: u64 = 180;
const NXSB_MAX_FS_OIDS: usize = 100;
const APSB_MAGIC_OFFSET: u64 = 32;
const APSB_VOLNAME_OFFSET: u64 = 572;
const APSB_VOLNAME_LEN: usize = 256;
const MIN_BLOCK_SIZE: u32 = 4096;
const MAX_BLOCK_SIZE: u32 = 65536;
#[derive(Debug)]
pub enum Error {
TooShort,
BadMagic,
BadBlockSize,
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::TooShort => write!(f, "image too short for an APFS container superblock"),
Error::BadMagic => write!(
f,
"APFS NX magic 'NXSB' (0x4253584e) not found at offset 32"
),
Error::BadBlockSize => write!(
f,
"APFS block_size is 0, not a power of two, or outside 4096–65536"
),
Error::Io(e) => write!(f, "APFS I/O error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
#[derive(Debug)]
pub struct NxSuperblock {
pub block_size: u32,
pub fs_oids: Vec<u64>,
}
pub fn detect<R: Read + Seek>(r: &mut R) -> Result<(), Error> {
let saved = r.stream_position()?;
let result = do_detect(r);
r.seek(SeekFrom::Start(saved))?;
result
}
fn do_detect<R: Read + Seek>(r: &mut R) -> Result<(), Error> {
r.seek(SeekFrom::Start(NXSB_MAGIC_OFFSET))?;
let mut buf = [0u8; 4];
match r.read_exact(&mut buf) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Err(Error::TooShort),
Err(e) => return Err(Error::Io(e)),
}
let magic = u32::from_le_bytes(buf);
if magic != NXSB_MAGIC {
return Err(Error::BadMagic);
}
Ok(())
}
pub fn read_nx_superblock<R: Read + Seek>(r: &mut R) -> Result<NxSuperblock, Error> {
r.seek(SeekFrom::Start(NXSB_MAGIC_OFFSET))?;
let mut buf4 = [0u8; 4];
r.read_exact(&mut buf4).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
let magic = u32::from_le_bytes(buf4);
if magic != NXSB_MAGIC {
return Err(Error::BadMagic);
}
r.seek(SeekFrom::Start(NXSB_BLOCK_SIZE_OFFSET))?;
r.read_exact(&mut buf4).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
let block_size = u32::from_le_bytes(buf4);
if !(MIN_BLOCK_SIZE..=MAX_BLOCK_SIZE).contains(&block_size)
|| (block_size & (block_size - 1)) != 0
{
return Err(Error::BadBlockSize);
}
r.seek(SeekFrom::Start(NXSB_FS_OID_OFFSET))?;
let mut fs_oids: Vec<u64> = Vec::new();
let mut buf8 = [0u8; 8];
for _ in 0..NXSB_MAX_FS_OIDS {
match r.read_exact(&mut buf8) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(Error::Io(e)),
}
let oid = u64::from_le_bytes(buf8);
if oid == 0 {
break;
}
fs_oids.push(oid);
}
Ok(NxSuperblock {
block_size,
fs_oids,
})
}
fn read_volume_name<R: Read + Seek>(r: &mut R, block_num: u64, block_size: u32) -> Option<String> {
let block_start = block_num * block_size as u64;
let magic_offset = block_start + APSB_MAGIC_OFFSET;
if r.seek(SeekFrom::Start(magic_offset)).is_err() {
return None;
}
let mut buf4 = [0u8; 4];
r.read_exact(&mut buf4).ok()?;
let magic = u32::from_le_bytes(buf4);
if magic != APSB_MAGIC {
return None;
}
let name_offset = block_start + APSB_VOLNAME_OFFSET;
if r.seek(SeekFrom::Start(name_offset)).is_err() {
return None;
}
let mut name_buf = [0u8; APSB_VOLNAME_LEN];
r.read_exact(&mut name_buf).ok()?;
let end = name_buf
.iter()
.position(|&b| b == 0)
.unwrap_or(APSB_VOLNAME_LEN);
let name = std::str::from_utf8(&name_buf[..end]).ok()?;
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub fn detect_and_parse<R: Read + Seek>(r: &mut R) -> Result<TreeNode, Error> {
let nx = read_nx_superblock(r)?;
let mut root = TreeNode::new_directory("/".to_string());
for &fs_oid in &nx.fs_oids {
let name = read_volume_name(r, fs_oid, nx.block_size)
.unwrap_or_else(|| format!("volume_{fs_oid}"));
root.add_child(TreeNode::new_directory(name));
}
root.calculate_directory_size();
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Seek, SeekFrom};
fn make_apfs_image(volname: &str) -> Vec<u8> {
const BLOCK_SIZE: usize = 4096;
let mut img = vec![0u8; BLOCK_SIZE * 2];
img[32..36].copy_from_slice(&NXSB_MAGIC.to_le_bytes());
img[36..40].copy_from_slice(&(BLOCK_SIZE as u32).to_le_bytes());
img[180..188].copy_from_slice(&1u64.to_le_bytes());
img[BLOCK_SIZE + 32..BLOCK_SIZE + 36].copy_from_slice(&APSB_MAGIC.to_le_bytes());
let name_bytes = volname.as_bytes();
let copy_len = name_bytes.len().min(APSB_VOLNAME_LEN - 1);
img[BLOCK_SIZE + 572..BLOCK_SIZE + 572 + copy_len].copy_from_slice(&name_bytes[..copy_len]);
img
}
#[test]
fn detect_valid_apfs() {
let img = make_apfs_image("TestVol");
let mut c = Cursor::new(&img);
assert!(detect(&mut c).is_ok(), "should detect valid APFS magic");
}
#[test]
fn detect_restores_cursor() {
let img = make_apfs_image("TestVol");
let mut c = Cursor::new(&img);
c.seek(SeekFrom::Start(42)).unwrap();
let _ = detect(&mut c);
assert_eq!(
c.stream_position().unwrap(),
42,
"detect must restore the cursor position"
);
}
#[test]
fn detect_restores_cursor_on_failure() {
let img = vec![0u8; 512];
let mut c = Cursor::new(&img);
c.seek(SeekFrom::Start(7)).unwrap();
let _ = detect(&mut c);
assert_eq!(
c.stream_position().unwrap(),
7,
"detect must restore cursor even on failure"
);
}
#[test]
fn detect_rejects_bad_magic() {
let mut img = make_apfs_image("TestVol");
img[32..36].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
let mut c = Cursor::new(&img);
assert!(
matches!(detect(&mut c), Err(Error::BadMagic)),
"should reject non-APFS magic"
);
}
#[test]
fn detect_rejects_too_short() {
let img = vec![0u8; 10];
let mut c = Cursor::new(&img);
assert!(
matches!(detect(&mut c), Err(Error::TooShort)),
"should return TooShort for a truncated image"
);
}
#[test]
fn nx_superblock_block_size() {
let img = make_apfs_image("TestVol");
let mut c = Cursor::new(&img);
let nx = read_nx_superblock(&mut c).expect("parse NX superblock");
assert_eq!(nx.block_size, 4096, "block_size should be 4096");
}
#[test]
fn nx_superblock_fs_oid_count() {
let img = make_apfs_image("TestVol");
let mut c = Cursor::new(&img);
let nx = read_nx_superblock(&mut c).expect("parse NX superblock");
assert_eq!(
nx.fs_oids.len(),
1,
"should find exactly one non-zero fs_oid"
);
assert_eq!(nx.fs_oids[0], 1, "fs_oid[0] should be 1");
}
#[test]
fn nx_superblock_rejects_bad_block_size_zero() {
let mut img = make_apfs_image("TestVol");
img[36..40].copy_from_slice(&0u32.to_le_bytes());
let mut c = Cursor::new(&img);
assert!(
matches!(read_nx_superblock(&mut c), Err(Error::BadBlockSize)),
"block_size=0 should be rejected"
);
}
#[test]
fn nx_superblock_rejects_non_power_of_two_block_size() {
let mut img = make_apfs_image("TestVol");
img[36..40].copy_from_slice(&5000u32.to_le_bytes());
let mut c = Cursor::new(&img);
assert!(
matches!(read_nx_superblock(&mut c), Err(Error::BadBlockSize)),
"block_size=5000 (not a power of 2) should be rejected"
);
}
#[test]
fn detect_and_parse_volume_name() {
let img = make_apfs_image("Macintosh HD");
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("detect_and_parse should succeed");
assert_eq!(tree.name, "/", "root node must be named '/'");
assert!(tree.is_directory, "root must be a directory");
assert_eq!(
tree.children.len(),
1,
"should have exactly one volume child"
);
assert_eq!(
tree.children[0].name, "Macintosh HD",
"volume name should match"
);
assert!(
tree.children[0].is_directory,
"volume node must be a directory"
);
}
#[test]
fn detect_and_parse_no_volumes() {
const BLOCK_SIZE: usize = 4096;
let mut img = vec![0u8; BLOCK_SIZE];
img[32..36].copy_from_slice(&NXSB_MAGIC.to_le_bytes());
img[36..40].copy_from_slice(&(BLOCK_SIZE as u32).to_le_bytes());
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("detect_and_parse with no volumes");
assert_eq!(tree.name, "/");
assert!(tree.children.is_empty(), "no volumes → no children");
}
#[test]
fn detect_and_parse_bad_magic_rejected() {
let img = vec![0u8; 4096];
let mut c = Cursor::new(&img);
assert!(
matches!(
detect_and_parse(&mut c),
Err(Error::BadMagic) | Err(Error::BadBlockSize)
),
"all-zeros image should be rejected"
);
}
#[test]
fn volume_node_has_no_file_location() {
let img = make_apfs_image("Preboot");
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("parse");
assert!(
tree.children[0].file_location.is_none(),
"volume directory node should have no file_location"
);
}
#[test]
fn error_display_too_short() {
let msg = format!("{}", Error::TooShort);
assert!(
msg.contains("too short") || msg.contains("short"),
"got: {msg}"
);
}
#[test]
fn error_display_bad_magic() {
let msg = format!("{}", Error::BadMagic);
assert!(msg.contains("NXSB") || msg.contains("magic"), "got: {msg}");
}
#[test]
fn error_display_bad_block_size() {
let msg = format!("{}", Error::BadBlockSize);
assert!(
msg.contains("block_size") || msg.contains("block"),
"got: {msg}"
);
}
#[test]
fn error_display_io() {
let io = std::io::Error::other("disk error");
let msg = format!("{}", Error::Io(io));
assert!(msg.contains("disk error"), "got: {msg}");
}
#[test]
fn error_source_io() {
use std::error::Error as StdError;
let io = std::io::Error::other("src");
assert!(Error::Io(io).source().is_some());
}
#[test]
fn error_source_non_io() {
use std::error::Error as StdError;
assert!(Error::TooShort.source().is_none());
assert!(Error::BadMagic.source().is_none());
assert!(Error::BadBlockSize.source().is_none());
}
#[test]
fn nx_superblock_rejects_block_size_too_large() {
let mut img = make_apfs_image("TestVol");
img[36..40].copy_from_slice(&131072u32.to_le_bytes());
let mut c = Cursor::new(&img);
assert!(
matches!(read_nx_superblock(&mut c), Err(Error::BadBlockSize)),
"block_size > MAX should be rejected"
);
}
#[test]
fn detect_and_parse_bad_apsb_uses_fallback_name() {
const BLOCK_SIZE: usize = 4096;
let mut img = vec![0u8; BLOCK_SIZE * 2];
img[32..36].copy_from_slice(&NXSB_MAGIC.to_le_bytes());
img[36..40].copy_from_slice(&(BLOCK_SIZE as u32).to_le_bytes());
img[180..188].copy_from_slice(&1u64.to_le_bytes());
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("fallback parse should succeed");
assert_eq!(tree.children.len(), 1);
assert_eq!(
tree.children[0].name, "volume_1",
"bad APSB → fallback name"
);
}
#[test]
fn detect_and_parse_empty_volume_name_uses_fallback() {
const BLOCK_SIZE: usize = 4096;
let mut img = vec![0u8; BLOCK_SIZE * 2];
img[32..36].copy_from_slice(&NXSB_MAGIC.to_le_bytes());
img[36..40].copy_from_slice(&(BLOCK_SIZE as u32).to_le_bytes());
img[180..188].copy_from_slice(&1u64.to_le_bytes()); img[BLOCK_SIZE + 32..BLOCK_SIZE + 36].copy_from_slice(&APSB_MAGIC.to_le_bytes());
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("empty name parse should succeed");
assert_eq!(tree.children.len(), 1);
assert_eq!(
tree.children[0].name, "volume_1",
"empty volname → fallback"
);
}
#[test]
fn nx_superblock_multiple_volumes() {
const BLOCK_SIZE: usize = 4096;
let mut img = vec![0u8; BLOCK_SIZE * 3];
img[32..36].copy_from_slice(&NXSB_MAGIC.to_le_bytes());
img[36..40].copy_from_slice(&(BLOCK_SIZE as u32).to_le_bytes());
img[180..188].copy_from_slice(&1u64.to_le_bytes()); img[188..196].copy_from_slice(&2u64.to_le_bytes()); let mut c = Cursor::new(&img);
let nx = read_nx_superblock(&mut c).expect("parse");
assert_eq!(nx.fs_oids.len(), 2);
assert_eq!(nx.fs_oids[0], 1);
assert_eq!(nx.fs_oids[1], 2);
}
#[test]
fn error_from_io_error() {
let io = std::io::Error::other("apfs read failed");
let e = Error::from(io);
assert!(matches!(e, Error::Io(_)));
}
#[test]
fn read_nx_superblock_magic_too_short_returns_error() {
let data = vec![0u8; 34];
let mut c = Cursor::new(data);
assert!(matches!(read_nx_superblock(&mut c), Err(Error::TooShort)));
}
#[test]
fn read_nx_superblock_block_size_too_short_returns_error() {
let mut data = vec![0u8; 38];
data[32..36].copy_from_slice(&NXSB_MAGIC.to_le_bytes());
let mut c = Cursor::new(data);
assert!(matches!(read_nx_superblock(&mut c), Err(Error::TooShort)));
}
}