#![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"
);
}
#[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());
}
}