#![cfg(all(unix, feature = "dmg"))]
use std::path::{Path, PathBuf};
use std::process::Command;
use fstool::block::{BlockDevice, DmgBackend};
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 skip_unless_macos_hdiutil() -> bool {
if !cfg!(target_os = "macos") {
eprintln!("skipping: DMG e2e requires macOS (hdiutil)");
return true;
}
if which("hdiutil").is_none() {
eprintln!("skipping: hdiutil not found on PATH");
return true;
}
if !hdiutil_usable() {
eprintln!("skipping: hdiutil refused to run `hdiutil help`");
return true;
}
false
}
fn create_hdiutil_dmg(dir: &Path, name: &str, format: &str) -> Option<PathBuf> {
let path = dir.join(format!("{name}.dmg"));
let out = Command::new("hdiutil")
.args([
"create",
"-size",
"8m",
"-fs",
"HFS+",
"-layout",
"NONE",
"-volname",
"FstoolDmgTest",
"-format",
format,
])
.arg(&path)
.output()
.expect("hdiutil create failed to spawn");
if !out.status.success() {
eprintln!(
"skipping {format}: hdiutil create refused:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
return None;
}
Some(path)
}
fn dmg_read_every_sector(format: &str) {
if skip_unless_macos_hdiutil() {
return;
}
let dir = tempfile::tempdir().unwrap();
let Some(path) = create_hdiutil_dmg(dir.path(), "rt", format) else {
return;
};
let mut dmg = match DmgBackend::open(&path) {
Ok(d) => d,
Err(e) => {
panic!("DmgBackend::open({format}) failed: {e}");
}
};
assert!(
dmg.total_size() > 0,
"DMG {format} virtual size is 0 — koly trailer parse failed?"
);
assert!(
dmg.chunk_count() > 0,
"DMG {format} has zero chunks — mish parse failed?"
);
const STRIDE: usize = 64 * 1024;
let size = dmg.total_size();
let mut buf = vec![0u8; STRIDE];
let mut off = 0u64;
while off < size {
let n = STRIDE.min((size - off) as usize);
dmg.read_at(off, &mut buf[..n]).unwrap_or_else(|e| {
panic!("DMG {format} read_at(off={off}, n={n}): {e}");
});
off += n as u64;
}
}
#[test]
fn dmg_udrw_reads_every_sector() {
dmg_read_every_sector("UDRW");
}
#[test]
fn dmg_udzo_zlib_reads_every_sector() {
dmg_read_every_sector("UDZO");
}
#[test]
fn dmg_udbz_bzip2_reads_every_sector() {
dmg_read_every_sector("UDBZ");
}
#[test]
fn dmg_ulfo_lzfse_reads_every_sector() {
dmg_read_every_sector("ULFO");
}
#[cfg(feature = "hfs-plus")]
#[test]
fn dmg_udzo_hfs_plus_walks_through_fstool() {
if skip_unless_macos_hdiutil() {
return;
}
let dir = tempfile::tempdir().unwrap();
let Some(path) = create_hdiutil_dmg(dir.path(), "hfs", "UDZO") else {
return;
};
let mut dmg = DmgBackend::open(&path).expect("DmgBackend::open");
let hfs = fstool::fs::hfs_plus::HfsPlus::open(&mut dmg).expect("HfsPlus::open");
let entries = hfs
.list_path(&mut dmg, "/")
.expect("list_path / on DMG-wrapped HFS+");
eprintln!(
"DMG-wrapped HFS+ root entries ({}): {:?}",
entries.len(),
entries.iter().map(|e| e.name.clone()).collect::<Vec<_>>()
);
}
#[cfg(feature = "dmg-encrypted")]
fn create_hdiutil_encrypted_dmg(
dir: &Path,
name: &str,
encryption: &str,
password: &str,
) -> Option<PathBuf> {
use std::io::Write as _;
use std::process::Stdio;
let path = dir.join(format!("{name}.dmg"));
let mut child = Command::new("hdiutil")
.args([
"create",
"-size",
"64k",
"-fs",
"MS-DOS",
"-layout",
"NONE",
"-volname",
"FSTOOLENC",
"-encryption",
encryption,
"-stdinpass",
"-ov",
])
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("hdiutil create failed to spawn");
child
.stdin
.take()
.unwrap()
.write_all(password.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
if !out.status.success() {
eprintln!(
"skipping {encryption}: hdiutil create refused:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
return None;
}
Some(path)
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn dmg_encrypted_hdiutil_images_decrypt() {
use fstool::block::dmg::EncryptedDmgBackend;
if skip_unless_macos_hdiutil() {
return;
}
let dir = tempfile::tempdir().unwrap();
for (encryption, key_bits) in [("AES-128", 128u32), ("AES-256", 256u32)] {
let Some(path) = create_hdiutil_encrypted_dmg(
dir.path(),
&format!("enc{key_bits}"),
encryption,
"hunter2",
) else {
continue;
};
assert!(fstool::block::dmg::probe_encrypted(&path).unwrap());
let mut be = EncryptedDmgBackend::open_with_password(&path, "hunter2")
.unwrap_or_else(|e| panic!("{encryption}: open_with_password: {e}"));
assert_eq!(be.header().key_bits, key_bits, "{encryption}: key_bits");
assert_eq!(be.total_size(), 64 * 1024, "{encryption}: data_size");
let mut boot = vec![0u8; 512];
be.read_at(0, &mut boot).unwrap();
assert_eq!(
&boot[510..512],
&[0x55, 0xAA],
"{encryption}: boot signature"
);
assert_eq!(
&boot[0x36..0x3E],
b"FAT12 ",
"{encryption}: FAT type string"
);
let mut all = vec![0u8; 64 * 1024];
be.read_at(0, &mut all).unwrap();
assert_eq!(&all[..512], &boot[..]);
let err = EncryptedDmgBackend::open_with_password(&path, "hunter3").unwrap_err();
assert!(
matches!(err, fstool::Error::Unsupported(_)),
"{encryption}: wrong password should be refused, got {err:?}"
);
}
}