#![cfg(unix)]
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
use fstool::block::{BlockDevice, FileBackend};
use fstool::fs::apfs::Apfs;
use fstool::fs::apfs::write::ApfsWriter;
use tempfile::NamedTempFile;
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 hdiutil_usable() -> bool {
Command::new("hdiutil")
.arg("help")
.output()
.map(|o| o.status.success() || o.status.code() == Some(0))
.unwrap_or(false)
}
fn parse_hdiutil_devices(plist: &str) -> (Vec<String>, Option<String>) {
let mut devs = Vec::new();
let mut whole: Option<String> = None;
for line in plist.lines() {
let mut rest = line;
while let Some(i) = rest.find("<string>/dev/disk") {
let after = &rest[i + "<string>".len()..];
if let Some(j) = after.find("</string>") {
let dev = after[..j].trim().to_string();
let is_whole = !dev.trim_start_matches("/dev/disk").contains('s');
if is_whole && whole.is_none() {
whole = Some(dev.clone());
}
devs.push(dev);
rest = &after[j + "</string>".len()..];
} else {
break;
}
}
}
if whole.is_none() {
if let Some(d) = devs.first() {
let tail = d.trim_start_matches("/dev/disk");
if let Some(idx) = tail.find('s') {
whole = Some(format!("/dev/disk{}", &tail[..idx]));
} else {
whole = Some(d.clone());
}
}
}
devs.sort();
devs.dedup();
(devs, whole)
}
fn hdiutil_detach(whole_disk: &str) {
let out = Command::new("hdiutil")
.arg("detach")
.arg("-force")
.arg(whole_disk)
.output();
match out {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"warn: hdiutil detach {whole_disk} failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr),
),
Err(e) => eprintln!("warn: hdiutil detach {whole_disk} could not run: {e}"),
}
}
#[test]
fn apfs_writer_passes_fsck_apfs() {
if !cfg!(target_os = "macos") {
eprintln!("skipping: APFS validation requires macOS (hdiutil + fsck_apfs)");
return;
}
if which("hdiutil").is_none() {
eprintln!("skipping: hdiutil not found on PATH");
return;
}
if which("fsck_apfs").is_none() {
eprintln!("skipping: fsck_apfs not found on PATH");
return;
}
if !hdiutil_usable() {
eprintln!("skipping: hdiutil refused to run `hdiutil help`");
return;
}
let bs = 4096u32;
let total_blocks = 4096u64; let img = NamedTempFile::new().unwrap();
{
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "FSCKVOL").unwrap();
let body = b"hello from fstool\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "readme", 0o644, &mut r, body.len() as u64)
.unwrap();
let etc = w.add_dir(2, "etc", 0o755).unwrap();
let conf = b"x=1\ny=2\n";
let mut r = Cursor::new(conf.as_ref());
w.add_file_from_reader(etc, "conf", 0o644, &mut r, conf.len() as u64)
.unwrap();
w.add_symlink(2, "lnk", 0o777, "/readme").unwrap();
w.add_xattr(2, "user.note", b"hello-xattr").unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
let attach = Command::new("hdiutil")
.args([
"attach",
"-nomount",
"-readonly",
"-imagekey",
"diskimage-class=CRawDiskImage",
"-plist",
])
.arg(img.path())
.output()
.expect("hdiutil attach failed to spawn");
if !attach.status.success() {
eprintln!(
"skipping: hdiutil attach refused our writer's image (likely too minimal for hdiutil):\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&attach.stdout),
String::from_utf8_lossy(&attach.stderr),
);
return;
}
let plist = String::from_utf8_lossy(&attach.stdout);
let (devs, whole) = parse_hdiutil_devices(&plist);
let whole = match whole {
Some(w) => w,
None => {
eprintln!("skipping: could not parse device node out of hdiutil plist:\n{plist}");
return;
}
};
let mut any_ran = false;
for dev in &devs {
let out = Command::new("fsck_apfs").arg("-n").arg(dev).output();
match out {
Ok(o) => {
any_ran = true;
let so = String::from_utf8_lossy(&o.stdout);
let se = String::from_utf8_lossy(&o.stderr);
eprintln!(
"fsck_apfs {dev} → exit={:?}, signal={:?}\nstdout:\n{so}\nstderr:\n{se}",
o.status.code(),
{
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
o.status.signal()
}
#[cfg(not(unix))]
{
None::<i32>
}
},
);
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert!(
o.status.signal().is_none(),
"fsck_apfs killed by signal {:?} on {dev}",
o.status.signal()
);
}
}
Err(e) => eprintln!("fsck_apfs {dev} could not run: {e}"),
}
}
hdiutil_detach(&whole);
assert!(
any_ran,
"fsck_apfs was never executed (no usable device nodes found in {devs:?})"
);
}
#[test]
fn apfs_reads_hdiutil_created_image() {
if !cfg!(target_os = "macos") {
eprintln!("skipping: APFS validation requires macOS (hdiutil)");
return;
}
if which("hdiutil").is_none() {
eprintln!("skipping: hdiutil not found on PATH");
return;
}
if !hdiutil_usable() {
eprintln!("skipping: hdiutil refused to run `hdiutil help`");
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("created.dmg");
let out = Command::new("hdiutil")
.args([
"create", "-size", "16m", "-fs", "APFS", "-volname", "HDIVOL", "-layout", "NONE", "-ov",
])
.arg(&path)
.output()
.expect("hdiutil create failed to spawn");
if !out.status.success() {
eprintln!(
"skipping: hdiutil create -fs APFS -layout NONE failed (older macOS?):\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
return;
}
let mut dev = FileBackend::open(&path).expect("FileBackend::open on hdiutil image");
let apfs = Apfs::open(&mut dev).expect("Apfs::open on hdiutil-created image");
assert_eq!(
apfs.volume_name(),
"HDIVOL",
"fstool read a different volume name than hdiutil set"
);
assert_eq!(apfs.block_size(), 4096);
}
#[test]
fn apfs_writer_round_trips_through_macos_mount() {
if !cfg!(target_os = "macos") {
eprintln!("skipping: APFS validation requires macOS (hdiutil)");
return;
}
if which("hdiutil").is_none() {
eprintln!("skipping: hdiutil not found on PATH");
return;
}
if !hdiutil_usable() {
eprintln!("skipping: hdiutil refused to run `hdiutil help`");
return;
}
let bs = 4096u32;
let total_blocks = 4096u64;
let img = NamedTempFile::new().unwrap();
let payload = b"round-trip via macOS VFS\n";
{
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "RTVOL").unwrap();
let mut r = Cursor::new(payload.as_ref());
w.add_file_from_reader(2, "rt.txt", 0o644, &mut r, payload.len() as u64)
.unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
let attach = Command::new("hdiutil")
.args([
"attach",
"-readonly",
"-imagekey",
"diskimage-class=CRawDiskImage",
"-plist",
])
.arg(img.path())
.output()
.expect("hdiutil attach failed to spawn");
if !attach.status.success() {
eprintln!(
"skipping: hdiutil refused to attach+mount our writer's image (expected with stub spaceman):\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&attach.stdout),
String::from_utf8_lossy(&attach.stderr),
);
return;
}
let plist = String::from_utf8_lossy(&attach.stdout);
let (_devs, whole) = parse_hdiutil_devices(&plist);
let whole = match whole {
Some(w) => w,
None => {
eprintln!("skipping: could not parse device node out of hdiutil plist:\n{plist}");
return;
}
};
let mut mount_point: Option<String> = None;
let mut in_mp_key = false;
for line in plist.lines() {
let t = line.trim();
if t.contains("<key>mount-point</key>") {
in_mp_key = true;
continue;
}
if in_mp_key {
if let Some(s) = t.strip_prefix("<string>") {
if let Some(end) = s.find("</string>") {
let mp = &s[..end];
if !mp.is_empty() {
mount_point = Some(mp.to_string());
break;
}
}
}
in_mp_key = false;
}
}
let mp = match mount_point {
Some(mp) => mp,
None => {
hdiutil_detach(&whole);
eprintln!(
"skipping: hdiutil attached the image but did not mount any volume (expected with our stub-spaceman writer)"
);
return;
}
};
let ls = Command::new("ls").arg(&mp).output();
let cat = Command::new("cat").arg(format!("{mp}/rt.txt")).output();
hdiutil_detach(&whole);
let ls = ls.expect("ls failed to spawn");
assert!(
ls.status.success(),
"ls {mp} failed:\n{}",
String::from_utf8_lossy(&ls.stderr)
);
let names = String::from_utf8_lossy(&ls.stdout);
assert!(
names.contains("rt.txt"),
"macOS VFS did not see /rt.txt; ls output: {names}"
);
let cat = cat.expect("cat failed to spawn");
assert!(
cat.status.success(),
"cat {mp}/rt.txt failed:\n{}",
String::from_utf8_lossy(&cat.stderr)
);
assert_eq!(
cat.stdout, payload,
"macOS VFS returned different bytes than we wrote"
);
}
#[test]
fn apfs_chmod_round_trips_through_macos_mount() {
if !cfg!(target_os = "macos") {
eprintln!("skipping: APFS validation requires macOS (hdiutil)");
return;
}
if which("hdiutil").is_none() || !hdiutil_usable() {
eprintln!("skipping: hdiutil not usable");
return;
}
let bs = 4096u32;
let total_blocks = 4096u64;
let img = NamedTempFile::new().unwrap();
let payload = b"mode-test\n";
{
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "MODEVOL").unwrap();
let mut r = Cursor::new(payload.as_ref());
w.add_file_from_reader(2, "perms.txt", 0o600, &mut r, payload.len() as u64)
.unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.chmod(&mut dev, "/perms.txt", 0o644).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let entries = fs.list_path(&mut dev, "/").expect("list root");
assert!(
entries.iter().any(|e| e.name == "perms.txt"),
"perms.txt missing post-chmod: {entries:?}"
);
}
let attach = Command::new("hdiutil")
.args([
"attach",
"-readonly",
"-imagekey",
"diskimage-class=CRawDiskImage",
"-plist",
])
.arg(img.path())
.output()
.expect("hdiutil attach failed to spawn");
if !attach.status.success() {
eprintln!(
"skipping macOS-mount cross-check (expected with stub-spaceman):\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&attach.stdout),
String::from_utf8_lossy(&attach.stderr)
);
return;
}
let plist = String::from_utf8_lossy(&attach.stdout);
let (_devs, whole) = parse_hdiutil_devices(&plist);
let whole = match whole {
Some(w) => w,
None => {
eprintln!("skipping: no device node in hdiutil plist");
return;
}
};
let mut mount_point: Option<String> = None;
let mut in_mp_key = false;
for line in plist.lines() {
let t = line.trim();
if t.contains("<key>mount-point</key>") {
in_mp_key = true;
continue;
}
if in_mp_key {
if let Some(s) = t.strip_prefix("<string>")
&& let Some(end) = s.find("</string>")
{
let mp = &s[..end];
if !mp.is_empty() {
mount_point = Some(mp.to_string());
break;
}
}
in_mp_key = false;
}
}
let mp = match mount_point {
Some(mp) => mp,
None => {
hdiutil_detach(&whole);
eprintln!("skipping: no mount point in hdiutil plist (stub-spaceman)");
return;
}
};
let stat = Command::new("stat")
.args(["-f", "%p"])
.arg(format!("{mp}/perms.txt"))
.output()
.expect("stat failed to spawn");
hdiutil_detach(&whole);
assert!(
stat.status.success(),
"stat failed:\n{}",
String::from_utf8_lossy(&stat.stderr)
);
let raw = String::from_utf8_lossy(&stat.stdout);
let mode_full = u32::from_str_radix(raw.trim(), 8).expect("octal parse");
assert_eq!(
mode_full & 0o7777,
0o644,
"expected 0o644 on perms.txt after chmod, got {mode_full:o}"
);
}
#[test]
fn apfs_chown_and_set_times_produce_clean_checkpoint() {
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let bs = 4096u32;
let total_blocks = 4096u64;
let img = NamedTempFile::new().unwrap();
{
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "META").unwrap();
let body = b"meta\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "m.txt", 0o644, &mut r, body.len() as u64)
.unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.chown(&mut dev, "/m.txt", 501, 20).unwrap();
let t = 1_700_000_000_000_000_000u64;
fs.set_times(&mut dev, "/m.txt", Some(t), Some(t), Some(t))
.unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let entries = fs.list_path(&mut dev, "/").unwrap();
assert!(
entries.iter().any(|e| e.name == "m.txt"),
"m.txt missing after chown + set_times: {entries:?}"
);
}
#[test]
fn apfs_rename_unlink_link_round_trips() {
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let bs = 4096u32;
let total_blocks = 4096u64;
let img = NamedTempFile::new().unwrap();
let payload = b"phase2\n";
{
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "P2VOL").unwrap();
let mut r = Cursor::new(payload.as_ref());
w.add_file_from_reader(2, "src.txt", 0o644, &mut r, payload.len() as u64)
.unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.rename(&mut dev, "/src.txt", "/renamed.txt").unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let names: Vec<String> = fs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(
names.contains(&"renamed.txt".to_string()),
"rename failed: {names:?}"
);
assert!(
!names.contains(&"src.txt".to_string()),
"old name lingers: {names:?}"
);
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.link(&mut dev, "/renamed.txt", "/alias.txt").unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let names: Vec<String> = fs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(names.contains(&"renamed.txt".to_string()));
assert!(
names.contains(&"alias.txt".to_string()),
"link missing: {names:?}"
);
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.remove_path(&mut dev, "/renamed.txt").unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let names: Vec<String> = fs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(!names.contains(&"renamed.txt".to_string()));
assert!(
names.contains(&"alias.txt".to_string()),
"alias must survive the first unlink: {names:?}"
);
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.remove_path(&mut dev, "/alias.txt").unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let names: Vec<String> = fs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(
!names.contains(&"alias.txt".to_string()),
"alias should be gone: {names:?}"
);
}
}
#[cfg(test)]
mod parser_tests {
use super::parse_hdiutil_devices;
#[test]
fn extracts_whole_disk_and_slices() {
let plist = r#"
<plist>
<dict>
<key>system-entities</key>
<array>
<dict>
<key>dev-entry</key>
<string>/dev/disk7</string>
</dict>
<dict>
<key>dev-entry</key>
<string>/dev/disk7s1</string>
</dict>
<dict>
<key>dev-entry</key>
<string>/dev/disk7s2</string>
</dict>
</array>
</dict>
</plist>
"#;
let (devs, whole) = parse_hdiutil_devices(plist);
assert_eq!(whole.as_deref(), Some("/dev/disk7"));
assert!(devs.contains(&"/dev/disk7".to_string()));
assert!(devs.contains(&"/dev/disk7s1".to_string()));
assert!(devs.contains(&"/dev/disk7s2".to_string()));
}
#[test]
fn derives_whole_disk_from_slice_only() {
let plist = "<string>/dev/disk9s2</string>";
let (devs, whole) = parse_hdiutil_devices(plist);
assert_eq!(whole.as_deref(), Some("/dev/disk9"));
assert_eq!(devs, vec!["/dev/disk9s2".to_string()]);
}
#[test]
fn empty_plist_yields_nothing() {
let (devs, whole) = parse_hdiutil_devices("");
assert!(devs.is_empty());
assert!(whole.is_none());
}
}