#![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()
&& 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>")
&& 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());
}
}
#[test]
fn apfs_write_state_create_file_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 mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "WSCF").unwrap();
let body = b"seed\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "seed.txt", 0o644, &mut r, body.len() as u64)
.unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
let new_payload = b"created in write state\n";
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_file_at(&mut dev, "/created.txt", new_payload, 0o644, 0)
.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(&"seed.txt".to_string()) && names.contains(&"created.txt".to_string()),
"missing entries after create: {names:?}"
);
let mut r = fs.open_file_reader(&mut dev, "/created.txt").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut r, &mut buf).unwrap();
assert_eq!(buf.as_slice(), new_payload, "created.txt body wrong");
}
#[test]
fn apfs_write_state_create_dir_then_nested_file() {
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 w = ApfsWriter::new(&mut dev, total_blocks, bs, "WSCD").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.create_dir_at(&mut dev, "/etc", 0o755, 0).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_file_at(&mut dev, "/etc/conf", b"k=v\n", 0o644, 0)
.unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let root_names: Vec<String> = fs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(
root_names.contains(&"etc".to_string()),
"/etc missing: {root_names:?}"
);
let etc_names: Vec<String> = fs
.list_path(&mut dev, "/etc")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(
etc_names.contains(&"conf".to_string()),
"/etc/conf missing: {etc_names:?}"
);
}
#[test]
fn apfs_write_state_create_symlink_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 mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let w = ApfsWriter::new(&mut dev, total_blocks, bs, "WSCS").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.create_symlink_at(&mut dev, "/link", "/usr/bin/sh", 0o777, 0)
.unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let mut r = fs.open_file_reader(&mut dev, "/link").unwrap();
let mut target = String::new();
std::io::Read::read_to_string(&mut r, &mut target).unwrap();
assert_eq!(target, "/usr/bin/sh", "symlink target wrong");
}
#[test]
fn apfs_write_state_set_xattr_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 mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total_blocks, bs, "WSXA").unwrap();
let body = b"xa\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "f.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.set_xattr(&mut dev, "/f.txt", "user.tag", b"v1").unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let xs = fs.read_xattrs(&mut dev, "/f.txt").unwrap();
assert_eq!(
xs.get("user.tag").map(|v| v.as_slice()),
Some(b"v1".as_ref()),
"xattr v1 missing: {xs:?}"
);
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.set_xattr(&mut dev, "/f.txt", "user.tag", b"v2-longer")
.unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let xs = fs.read_xattrs(&mut dev, "/f.txt").unwrap();
assert_eq!(
xs.get("user.tag").map(|v| v.as_slice()),
Some(b"v2-longer".as_ref()),
"xattr replacement did not stick: {xs:?}"
);
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.remove_xattr(&mut dev, "/f.txt", "user.tag").unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let xs = fs.read_xattrs(&mut dev, "/f.txt").unwrap();
assert!(!xs.contains_key("user.tag"), "xattr lingered: {xs:?}");
}
#[test]
fn apfs_filesystem_trait_dispatches_to_write_state() {
use fstool::fs::{Filesystem, SetAttrs};
use std::path::Path;
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 w = ApfsWriter::new(&mut dev, total_blocks, bs, "TRAIT").unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
let body: Vec<u8> = b"alpha\n".to_vec();
let body_len = body.len() as u64;
Filesystem::create_file(
&mut fs,
&mut dev,
Path::new("/a.txt"),
fstool::fs::FileSource::Reader {
reader: Box::new(Cursor::new(body)),
len: body_len,
},
fstool::fs::FileMeta {
mode: 0o644,
..Default::default()
},
)
.unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::create_dir(
&mut fs,
&mut dev,
Path::new("/sub"),
fstool::fs::FileMeta {
mode: 0o755,
..Default::default()
},
)
.unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::set_attrs(
&mut fs,
&mut dev,
Path::new("/a.txt"),
SetAttrs {
mode: Some(0o600),
uid: Some(501),
gid: Some(20),
mtime: Some(1_700_000_000),
ctime: Some(1_700_000_000),
atime: Some(1_700_000_000),
},
)
.unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::set_xattr(
&mut fs,
&mut dev,
Path::new("/a.txt"),
"user.role",
b"trait",
)
.unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::rename(&mut fs, &mut dev, Path::new("/a.txt"), Path::new("/b.txt")).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::remove(&mut fs, &mut dev, Path::new("/sub")).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(&"b.txt".to_string()),
"/b.txt missing: {names:?}"
);
assert!(!names.contains(&"a.txt".to_string()), "old name lingers");
assert!(
!names.contains(&"sub".to_string()),
"/sub not removed: {names:?}"
);
let xs = fs.read_xattrs(&mut dev, "/b.txt").unwrap();
assert_eq!(
xs.get("user.role").map(|v| v.as_slice()),
Some(b"trait".as_ref()),
"trait set_xattr did not stick: {xs:?}"
);
}
#[test]
fn apfs_filesystem_trait_refuses_mutations_on_read_state() {
use fstool::fs::{Filesystem, SetAttrs};
use std::path::Path;
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, "RDST").unwrap();
let body = b"r\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "f.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(&mut dev).unwrap();
assert!(matches!(
Filesystem::remove(&mut fs, &mut dev, Path::new("/f.txt")),
Err(fstool::Error::Unsupported(_))
));
assert!(matches!(
Filesystem::rename(&mut fs, &mut dev, Path::new("/f.txt"), Path::new("/g.txt")),
Err(fstool::Error::Unsupported(_))
));
assert!(matches!(
Filesystem::set_attrs(
&mut fs,
&mut dev,
Path::new("/f.txt"),
SetAttrs {
mode: Some(0o600),
..Default::default()
},
),
Err(fstool::Error::Unsupported(_))
));
assert!(matches!(
Filesystem::set_xattr(&mut fs, &mut dev, Path::new("/f.txt"), "n", b"v"),
Err(fstool::Error::Unsupported(_))
));
}
#[test]
fn apfs_xp_desc_ring_buffer_survives_many_checkpoints() {
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let bs = 4096u32;
let total_blocks = 8192u64; let img = NamedTempFile::new().unwrap();
{
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let w = ApfsWriter::new(&mut dev, total_blocks, bs, "RING").unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
let n_cycles = 25usize;
for i in 0..n_cycles {
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
let path = format!("/file-{i:03}");
let body = format!("body-{i}\n");
fs.create_file_at(&mut dev, &path, body.as_bytes(), 0o644, 0)
.expect("create_file_at past slot 15 (ring should wrap)");
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let names: std::collections::HashSet<String> = fs
.list_path(&mut dev, "/")
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
for i in 0..n_cycles {
let want = format!("file-{i:03}");
assert!(
names.contains(&want),
"cycle {i}: {want} missing after ring rotation; saw {names:?}"
);
}
let last = format!("/file-{:03}", n_cycles - 1);
let mut r = fs.open_file_reader(&mut dev, &last).unwrap();
let mut body = String::new();
std::io::Read::read_to_string(&mut r, &mut body).unwrap();
assert_eq!(body, format!("body-{}\n", n_cycles - 1));
}
#[test]
fn cli_add_rm_reach_apfs_write_state() {
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let bin = env!("CARGO_BIN_EXE_fstool");
let dir = tempfile::tempdir().unwrap();
let img = dir.path().join("v.apfs");
{
let bs = 4096u32;
let total = 4096u64;
let mut dev = FileBackend::create(&img, total * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total, bs, "CLI").unwrap();
let body = b"seed\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "seed.txt", 0o644, &mut r, body.len() as u64)
.unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
let host = dir.path().join("host.txt");
std::fs::write(&host, b"cli-added\n").unwrap();
let r = Command::new(bin)
.arg("add")
.arg(&img)
.arg(&host)
.arg("/added.txt")
.output()
.unwrap();
assert!(
r.status.success(),
"fstool add on apfs failed: {}",
String::from_utf8_lossy(&r.stderr)
);
let ls = Command::new(bin)
.arg("ls")
.arg(&img)
.arg("/")
.output()
.unwrap();
assert!(ls.status.success());
let listing = String::from_utf8_lossy(&ls.stdout);
assert!(
listing.contains("added.txt"),
"/added.txt missing: {listing}"
);
let r = Command::new(bin)
.arg("rm")
.arg(&img)
.arg("/seed.txt")
.output()
.unwrap();
assert!(
r.status.success(),
"fstool rm on apfs failed: {}",
String::from_utf8_lossy(&r.stderr)
);
let ls = Command::new(bin)
.arg("ls")
.arg(&img)
.arg("/")
.output()
.unwrap();
assert!(ls.status.success());
let listing = String::from_utf8_lossy(&ls.stdout);
assert!(!listing.contains("seed.txt"), "rm did not stick: {listing}");
}
#[test]
fn apfs_create_file_preserves_mtime() {
use fstool::fs::{FileMeta, FileSource, Filesystem};
use std::path::Path;
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 mtime: u32 = 1_700_000_000; {
let mut dev = FileBackend::create(img.path(), total_blocks * bs as u64).unwrap();
let w = ApfsWriter::new(&mut dev, total_blocks, bs, "MTIME").unwrap();
w.finish().unwrap();
dev.sync().unwrap();
drop(dev);
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
let body = b"t\n".to_vec();
let body_len = body.len() as u64;
Filesystem::create_file(
&mut fs,
&mut dev,
Path::new("/t.txt"),
FileSource::Reader {
reader: Box::new(Cursor::new(body)),
len: body_len,
},
FileMeta {
mode: 0o644,
mtime,
..Default::default()
},
)
.unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open(&mut dev).unwrap();
let attrs = Filesystem::getattr(&mut fs, &mut dev, Path::new("/t.txt")).unwrap();
assert_eq!(
attrs.mtime, mtime,
"mtime did not round-trip; got {} expected {mtime}",
attrs.mtime
);
}
#[test]
fn apfs_filesystem_truncate_round_trips() {
use fstool::fs::Filesystem;
use std::path::Path;
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let bs = 4096u32;
let total = 4096u64;
let img = NamedTempFile::new().unwrap();
{
let mut dev = FileBackend::create(img.path(), total * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total, bs, "TRUN").unwrap();
let body = b"hello world\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "t.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();
Filesystem::truncate(&mut fs, &mut dev, Path::new("/t.txt"), 5).unwrap();
dev.sync().unwrap();
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let mut r = fs.open_file_reader(&mut dev, "/t.txt").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut r, &mut buf).unwrap();
assert_eq!(buf.as_slice(), b"hello", "shrink failed: {buf:?}");
}
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::truncate(&mut fs, &mut dev, Path::new("/t.txt"), 8).unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let fs = Apfs::open(&mut dev).unwrap();
let mut r = fs.open_file_reader(&mut dev, "/t.txt").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut r, &mut buf).unwrap();
assert_eq!(
buf.as_slice(),
b"hello\0\0\0",
"grow zero-fill wrong: {buf:?}"
);
}
#[test]
fn apfs_filesystem_list_xattrs_sorted() {
use fstool::fs::Filesystem;
use std::path::Path;
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let bs = 4096u32;
let total = 4096u64;
let img = NamedTempFile::new().unwrap();
{
let mut dev = FileBackend::create(img.path(), total * bs as u64).unwrap();
let mut w = ApfsWriter::new(&mut dev, total, bs, "XATT").unwrap();
let body = b"x\n";
let mut r = Cursor::new(body.as_ref());
w.add_file_from_reader(2, "f.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();
Filesystem::set_xattr(&mut fs, &mut dev, Path::new("/f.txt"), "user.zeta", b"z").unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::set_xattr(&mut fs, &mut dev, Path::new("/f.txt"), "user.alpha", b"a").unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
Filesystem::set_xattr(&mut fs, &mut dev, Path::new("/f.txt"), "user.mid", b"m").unwrap();
dev.sync().unwrap();
}
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open(&mut dev).unwrap();
let xs = Filesystem::list_xattrs(&mut fs, &mut dev, Path::new("/f.txt")).unwrap();
let names: Vec<&str> = xs.iter().map(|x| x.name.as_str()).collect();
assert_eq!(
names,
vec!["user.alpha", "user.mid", "user.zeta"],
"got {names:?}"
);
}
fn synthesize_hashed_key_volume(path: &std::path::Path, also_case_insensitive: bool) {
use std::os::unix::fs::FileExt;
let bs = 4096u64;
let total = 4096u64;
{
let mut dev = FileBackend::create(path, total * bs).unwrap();
let w = ApfsWriter::new(&mut dev, total, bs as u32, "NRMI").unwrap();
w.finish().unwrap();
dev.sync().unwrap();
}
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
let mut apsb_paddr: Option<u64> = None;
let mut buf = [0u8; 4];
for paddr in 0..total {
f.read_exact_at(&mut buf, paddr * bs + 32).unwrap();
if &buf == b"APSB" {
apsb_paddr = Some(paddr);
break;
}
}
let paddr = apsb_paddr.expect("no APSB found in image");
let mut incompat = [0u8; 8];
f.read_exact_at(&mut incompat, paddr * bs + 56).unwrap();
let mut v = u64::from_le_bytes(incompat);
v |= 0x0000_0008; if also_case_insensitive {
v |= 0x0000_0001; }
f.write_at(&v.to_le_bytes(), paddr * bs + 56).unwrap();
f.sync_all().unwrap();
}
#[test]
fn apfs_hashed_create_file_round_trips() {
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let img = NamedTempFile::new().unwrap();
synthesize_hashed_key_volume(img.path(), false);
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev)
.expect("open_writable accepts hashed-key volume after commit E");
fs.create_file_at(&mut dev, "/created.txt", b"hashed\n", 0o644, 0)
.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(&"created.txt".to_string()),
"created.txt missing after hashed write: {names:?}"
);
}
#[test]
fn apfs_hashed_create_case_fold_detects_conflict() {
if !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
eprintln!("skipping: APFS validation needs unix");
return;
}
let img = NamedTempFile::new().unwrap();
synthesize_hashed_key_volume(img.path(), true);
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_file_at(&mut dev, "/Foo.txt", b"a\n", 0o644, 0)
.unwrap();
dev.sync().unwrap();
drop(dev);
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(&"Foo.txt".to_string()),
"Foo.txt missing on case-insensitive hashed volume: {names:?}"
);
}
#[test]
fn apfs_drec_hash_known_vectors() {
use fstool::fs::apfs::write::apfs_drec_name_len_and_hash;
let h_foo = apfs_drec_name_len_and_hash("foo", false);
assert_eq!(h_foo & 0x3FF, 4, "name_len mismatch for foo");
let h_fooo = apfs_drec_name_len_and_hash("Foo", true);
let h_foo_f = apfs_drec_name_len_and_hash("foo", true);
assert_eq!(
h_fooo, h_foo_f,
"Foo and foo should hash identically under case-fold"
);
let h_foo_cap = apfs_drec_name_len_and_hash("Foo", false);
assert_ne!(
h_foo_cap, h_foo,
"Foo and foo should hash differently without case-fold"
);
let h_decomposed = apfs_drec_name_len_and_hash("cafe\u{0301}", false);
let h_precomposed = apfs_drec_name_len_and_hash("caf\u{00E9}", false);
assert_eq!(
h_decomposed & !0x3FF,
h_precomposed & !0x3FF,
"NFD should fuse the two café spellings"
);
}
#[test]
fn apfs_open_writable_create_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 w = ApfsWriter::new(&mut dev, total_blocks, bs, "FSCKOPW").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.create_file_at(&mut dev, "/readme", b"hello from open_writable\n", 0o644, 0)
.unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_dir_at(&mut dev, "/etc", 0o755, 0).unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_file_at(&mut dev, "/etc/conf", b"x=1\ny=2\n", 0o644, 0)
.unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_symlink_at(&mut dev, "/lnk", "/readme", 0o777, 0)
.unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.set_xattr(&mut dev, "/readme", "user.note", b"open_writable-xattr")
.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 open_writable image:\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_hashed_open_writable_create_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 img = NamedTempFile::new().unwrap();
synthesize_hashed_key_volume(img.path(), true);
{
let mut dev = FileBackend::open(img.path()).unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_file_at(&mut dev, "/readme", b"hashed open_writable\n", 0o644, 0)
.unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_dir_at(&mut dev, "/etc", 0o755, 0).unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_file_at(&mut dev, "/etc/conf", b"x=1\ny=2\n", 0o644, 0)
.unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.create_symlink_at(&mut dev, "/lnk", "/readme", 0o777, 0)
.unwrap();
dev.sync().unwrap();
let mut fs = Apfs::open_writable(&mut dev).unwrap();
fs.set_xattr(&mut dev, "/readme", "user.note", b"hashed-xattr")
.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 hashed-key image:\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 (hashed) {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 (hashed) {dev} could not run: {e}"),
}
}
hdiutil_detach(&whole);
assert!(
any_ran,
"fsck_apfs (hashed) was never executed (no usable device nodes found in {devs:?})"
);
}