#![cfg(unix)]
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
use fstool::block::{BlockDevice, FileBackend};
use fstool::fs::hfs_plus::{FormatOpts, HfsPlus};
use tempfile::NamedTempFile;
const VOL_BYTES: u64 = 8 * 1024 * 1024;
fn which(tool: &str) -> Option<PathBuf> {
let out = Command::new("sh")
.arg("-c")
.arg(format!("command -v {tool}"))
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let p = s.trim();
if p.is_empty() { None } else { Some(p.into()) }
}
fn find_fsck_hfs() -> Option<(PathBuf, &'static str)> {
for (name, label) in [("fsck.hfs", "fsck.hfs"), ("fsck.hfsplus", "fsck.hfsplus")] {
if let Some(p) = which(name) {
if Command::new(&p).arg("-V").output().is_ok() {
return Some((p, label));
}
}
}
None
}
fn fresh_image(tmp: &NamedTempFile, opts: &FormatOpts) -> (FileBackend, HfsPlus) {
let mut dev = FileBackend::create(tmp.path(), VOL_BYTES).unwrap();
let hfs = HfsPlus::format(&mut dev, opts).unwrap();
(dev, hfs)
}
fn assert_fsck_clean(fsck: &std::path::Path, label: &str, image: &std::path::Path) {
let out = Command::new(fsck).arg("-nf").arg(image).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"{label} -nf failed on {}:\nstatus: {}\nstdout:\n{stdout}\nstderr:\n{stderr}",
image.display(),
out.status
);
let combined = format!("{stdout}\n{stderr}");
for bad in [
"Invalid",
"INVALID",
"corrupt",
"CORRUPT",
"** Repairs are needed",
"The volume needs to be repaired",
"could not be verified",
] {
assert!(
!combined.contains(bad),
"{label} reported `{bad}` on {}:\n{combined}",
image.display()
);
}
}
#[test]
fn writer_image_passes_fsck_hfs() {
let Some((fsck, label)) = find_fsck_hfs() else {
eprintln!("skipping: fsck.hfs / fsck.hfsplus not installed");
return;
};
let tmp = NamedTempFile::new().unwrap();
let opts = FormatOpts {
volume_name: "FstoolHFS".into(),
..FormatOpts::default()
};
let (mut dev, mut hfs) = fresh_image(&tmp, &opts);
hfs.create_dir(&mut dev, "/etc", 0o755, 0, 0).unwrap();
let body = b"x=1\n";
let mut src = Cursor::new(&body[..]);
hfs.create_file(
&mut dev,
"/etc/conf",
&mut src,
body.len() as u64,
0o644,
0,
0,
)
.unwrap();
let big: Vec<u8> = (0..16 * 1024).map(|i| (i & 0xFF) as u8).collect();
let mut src = Cursor::new(&big[..]);
hfs.create_file(&mut dev, "/readme", &mut src, big.len() as u64, 0o644, 0, 0)
.unwrap();
hfs.create_symlink(&mut dev, "/link", "etc/conf", 0o777, 0, 0)
.unwrap();
hfs.create_hardlink(&mut dev, "/readme", "/alias").unwrap();
hfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
assert_fsck_clean(&fsck, label, tmp.path());
}
#[test]
fn writer_journaled_image_passes_fsck_hfs() {
let Some((fsck, label)) = find_fsck_hfs() else {
eprintln!("skipping: fsck.hfs / fsck.hfsplus not installed");
return;
};
let tmp = NamedTempFile::new().unwrap();
let opts = FormatOpts {
volume_name: "FstoolJrnl".into(),
journaled: true,
..FormatOpts::default()
};
let (mut dev, mut hfs) = fresh_image(&tmp, &opts);
let body = b"journaled hello\n";
let mut src = Cursor::new(&body[..]);
hfs.create_file(
&mut dev,
"/hello.txt",
&mut src,
body.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
assert_fsck_clean(&fsck, label, tmp.path());
}
#[test]
fn newfs_hfsplus_image_opens_via_fstool() {
let Some(newfs) = which("newfs_hfsplus").or_else(|| which("newfs_hfs")) else {
eprintln!("skipping: newfs_hfsplus / newfs_hfs not installed");
return;
};
let tmp = NamedTempFile::new().unwrap();
std::fs::File::create(tmp.path())
.and_then(|f| f.set_len(VOL_BYTES))
.unwrap();
let out = Command::new(&newfs)
.arg("-v")
.arg("ExtHFS")
.arg(tmp.path())
.output()
.unwrap();
if !out.status.success() {
eprintln!(
"skipping: {} refused to format image: {}",
newfs.display(),
String::from_utf8_lossy(&out.stderr)
);
return;
}
let mut dev = FileBackend::open(tmp.path()).unwrap();
let hfs = HfsPlus::open(&mut dev).expect("fstool failed to open newfs image");
let entries = hfs.list_path(&mut dev, "/").unwrap();
assert!(
entries.is_empty(),
"freshly-formatted root should be empty, got: {entries:?}"
);
}
#[test]
#[ignore = "diagnostic — run explicitly via `cargo test -- --ignored`"]
fn dump_mkfs_vs_fstool_extents_header() {
let Some(newfs) = which("mkfs.hfsplus")
.or_else(|| which("newfs_hfsplus"))
.or_else(|| which("newfs_hfs"))
else {
eprintln!("skipping: no native hfs+ formatter on PATH");
return;
};
let mkfs_tmp = NamedTempFile::new().unwrap();
std::fs::File::create(mkfs_tmp.path())
.and_then(|f| f.set_len(VOL_BYTES))
.unwrap();
let out = Command::new(&newfs)
.arg("-v")
.arg("MkfsHFS")
.arg(mkfs_tmp.path())
.output()
.unwrap();
if !out.status.success() {
eprintln!(
"skipping: {} refused to format: {}\n{}",
newfs.display(),
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout),
);
return;
}
let mkfs_bytes = std::fs::read(mkfs_tmp.path()).unwrap();
let fstool_tmp = NamedTempFile::new().unwrap();
{
let mut dev = FileBackend::create(fstool_tmp.path(), VOL_BYTES).unwrap();
let opts = FormatOpts {
volume_name: "FstoolHFS".into(),
..FormatOpts::default()
};
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
hfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
}
let fstool_bytes = std::fs::read(fstool_tmp.path()).unwrap();
let parse = |buf: &[u8], label: &str| -> String {
let vh = &buf[1024..1024 + 512];
let sig = u16::from_be_bytes([vh[0], vh[1]]);
let bs = u32::from_be_bytes(vh[0x28..0x2C].try_into().unwrap()) as usize;
let ext_start = u32::from_be_bytes(vh[0xC0 + 16..0xC0 + 20].try_into().unwrap()) as usize;
let ext_clump = u32::from_be_bytes(vh[0xC0 + 8..0xC0 + 12].try_into().unwrap());
let ext_total = u32::from_be_bytes(vh[0xC0 + 12..0xC0 + 16].try_into().unwrap());
let ext_off = ext_start * bs;
let header_bytes = &buf[ext_off..ext_off + 256];
let mut s = String::new();
s += &format!("== {label} ==\n");
s += &format!(
" VH sig=0x{:04x} bs={} ext_start={} ext_clump={} ext_total={}\n",
sig, bs, ext_start, ext_clump, ext_total
);
s += &format!(
" header byte 0..32 (descriptor):\n {:02x?}\n",
&header_bytes[..32]
);
s += &format!(
" header byte 32..64 (start of BTHeaderRec):\n {:02x?}\n",
&header_bytes[32..64]
);
s += &format!(" header byte 64..96:\n {:02x?}\n", &header_bytes[64..96]);
s += &format!(
" header byte 96..128:\n {:02x?}\n",
&header_bytes[96..128]
);
s += &format!(
" header byte 128..256 (user/map area):\n {:02x?}\n",
&header_bytes[128..256]
);
let ns_field = u16::from_be_bytes(header_bytes[32..34].try_into().unwrap()) as usize;
let node_end = ext_off + ns_field;
s += &format!(
" nodeSize={} → tail offsets: {:02x?}\n",
ns_field,
&buf[node_end - 16..node_end]
);
s
};
let m = parse(&mkfs_bytes, "mkfs.hfsplus");
let f = parse(&fstool_bytes, "fstool");
panic!("\n{m}\n{f}");
}
#[test]
#[ignore = "diagnostic — run via `cargo test -- --ignored`"]
fn dump_fstool_catalog_leaf() {
let tmp = NamedTempFile::new().unwrap();
let opts = FormatOpts {
volume_name: "FstoolHFS".into(),
..FormatOpts::default()
};
let (mut dev, mut hfs) = fresh_image(&tmp, &opts);
hfs.create_dir(&mut dev, "/etc", 0o755, 0, 0).unwrap();
let body = b"x=1\n";
let mut src = Cursor::new(&body[..]);
hfs.create_file(
&mut dev,
"/etc/conf",
&mut src,
body.len() as u64,
0o644,
0,
0,
)
.unwrap();
let big: Vec<u8> = (0..16 * 1024).map(|i| (i & 0xFF) as u8).collect();
let mut src = Cursor::new(&big[..]);
hfs.create_file(&mut dev, "/readme", &mut src, big.len() as u64, 0o644, 0, 0)
.unwrap();
hfs.create_symlink(&mut dev, "/link", "etc/conf", 0o777, 0, 0)
.unwrap();
hfs.create_hardlink(&mut dev, "/readme", "/alias").unwrap();
hfs.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
let data = std::fs::read(tmp.path()).unwrap();
let vh = &data[1024..1024 + 512];
let bs = u32::from_be_bytes(vh[0x28..0x2C].try_into().unwrap()) as usize;
let cat_start = u32::from_be_bytes(vh[0x110 + 16..0x110 + 20].try_into().unwrap()) as usize;
let cat_off = cat_start * bs;
let h = &data[cat_off..cat_off + 256];
let node_size = u16::from_be_bytes(h[32..34].try_into().unwrap()) as usize;
let first_leaf = u32::from_be_bytes(h[24..28].try_into().unwrap()) as usize;
let leaf_off = cat_off + first_leaf * node_size;
let n = &data[leaf_off..leaf_off + node_size];
let num_records = u16::from_be_bytes(n[10..12].try_into().unwrap()) as usize;
let mut offs = Vec::new();
for i in 0..(num_records + 1) {
let p = node_size - 2 * (i + 1);
offs.push(u16::from_be_bytes(n[p..p + 2].try_into().unwrap()) as usize);
}
let mut out = String::new();
out += &format!(
"first leaf has {} records, node_size={}\n",
num_records, node_size
);
for i in 0..num_records {
let s = offs[i];
let e = offs[i + 1];
let rec = &n[s..e];
let key_len = u16::from_be_bytes(rec[0..2].try_into().unwrap()) as usize;
let parent = u32::from_be_bytes(rec[2..6].try_into().unwrap());
let name_len = u16::from_be_bytes(rec[6..8].try_into().unwrap()) as usize;
let mut name = String::new();
for j in 0..name_len {
let bo = 8 + 2 * j;
let u = u16::from_be_bytes(rec[bo..bo + 2].try_into().unwrap());
if u == 0 {
name.push_str("\\0");
} else if u < 0x80 {
name.push(u as u8 as char);
} else {
name.push_str(&format!("\\u{{{:x}}}", u));
}
}
let body_start = 2 + key_len + ((2 + key_len) & 1); let body_len = e - s - body_start;
let rec_type_bytes = &rec[body_start..body_start.min(rec.len() - 2) + 2];
let rec_type = if rec_type_bytes.len() >= 2 {
i16::from_be_bytes([rec_type_bytes[0], rec_type_bytes[1]])
} else {
-99
};
out += &format!(
"[{i:02}] key_len={key_len} parent={parent} name=\"{name}\" body_start={body_start} body_len={body_len} rec_type={rec_type}\n"
);
let body_end = (body_start + 32).min(e - s);
out += &format!(
" body[0..{}]: {:02x?}\n",
body_end - body_start,
&rec[body_start..body_end]
);
}
panic!("\n{}", out);
}