pub mod attributes;
pub mod btree;
pub mod catalog;
pub mod decmpfs;
pub mod extents;
pub mod handle;
pub(crate) mod journal;
pub mod volume_header;
pub mod writer;
pub use writer::FormatOpts;
use std::io::Read;
use crate::Result;
use crate::block::BlockDevice;
use attributes::Attributes;
use btree::ForkReader;
use catalog::{Catalog, CatalogFile, CatalogKey, CatalogRecord, ROOT_FOLDER_ID, UniStr, mode};
use extents::ExtentsOverflow;
use volume_header::{ForkData, VolumeHeader, read_volume_header};
const SYMLINK_MAX_BYTES: u64 = 4096;
pub struct HfsPlus {
volume_header: VolumeHeader,
catalog: Catalog,
overflow: Option<ExtentsOverflow>,
attributes: Option<Attributes>,
private_dir_cnid: std::cell::Cell<Option<u32>>,
private_dir_resolved: std::cell::Cell<bool>,
volume_name: String,
writer: Option<writer::Writer>,
}
impl HfsPlus {
pub fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
let vh = read_volume_header(dev)?;
journal::replay(dev, &vh)?;
let vh = read_volume_header(dev)?;
let case_sensitive = vh.is_hfsx();
let cat_fork = ForkReader::from_inline(&vh.catalog_file, vh.block_size, "catalog")?;
let catalog = Catalog::open(dev, cat_fork, case_sensitive)?;
let overflow = if vh.extents_file.total_blocks > 0 {
let ext_fork =
ForkReader::from_inline(&vh.extents_file, vh.block_size, "extents-overflow")?;
Some(ExtentsOverflow::open(dev, ext_fork)?)
} else {
None
};
let attributes = open_attributes(dev, &vh, case_sensitive)?;
let volume_name = lookup_thread_name(dev, &catalog, ROOT_FOLDER_ID)?
.unwrap_or_else(|| "Untitled".to_string());
let writer = writer::open_writable(dev, &vh, volume_name.clone())?;
Ok(Self {
volume_header: vh,
catalog,
overflow,
attributes,
private_dir_cnid: std::cell::Cell::new(None),
private_dir_resolved: std::cell::Cell::new(false),
volume_name,
writer: Some(writer),
})
}
pub fn format(dev: &mut dyn BlockDevice, opts: &writer::FormatOpts) -> Result<Self> {
let (vh, w) = writer::format(dev, opts)?;
let case_sensitive = vh.is_hfsx();
let mut w = w;
let mut vh_mut = vh.clone();
writer::flush(&mut w, &mut vh_mut, dev)?;
w.flushed = false;
let cat_fork = ForkReader::from_inline(&vh_mut.catalog_file, vh_mut.block_size, "catalog")?;
let catalog = Catalog::open(dev, cat_fork, case_sensitive)?;
let overflow = if vh_mut.extents_file.total_blocks > 0 {
let ext_fork = ForkReader::from_inline(
&vh_mut.extents_file,
vh_mut.block_size,
"extents-overflow",
)?;
Some(ExtentsOverflow::open(dev, ext_fork)?)
} else {
None
};
let attributes = open_attributes(dev, &vh_mut, case_sensitive)?;
let volume_name = w.volume_name.clone();
Ok(Self {
volume_header: vh_mut,
catalog,
overflow,
attributes,
private_dir_cnid: std::cell::Cell::new(None),
private_dir_resolved: std::cell::Cell::new(false),
volume_name,
writer: Some(w),
})
}
pub fn create_dir(
&mut self,
_dev: &mut dyn BlockDevice,
path: &str,
mode: u16,
uid: u32,
gid: u32,
) -> Result<u32> {
let (parent_id, name) = self.resolve_create_target(path)?;
let w = self
.writer
.as_mut()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: volume is read-only".into()))?;
let cnid = w.next_cnid;
w.next_cnid = w
.next_cnid
.checked_add(1)
.ok_or_else(|| crate::Error::Unsupported("hfs+: CNID space exhausted".into()))?;
writer::insert_folder(w, parent_id, &name, cnid, mode, uid, gid)?;
Ok(cnid)
}
#[allow(clippy::too_many_arguments)]
pub fn create_file<R: std::io::Read>(
&mut self,
dev: &mut dyn BlockDevice,
path: &str,
src: &mut R,
len: u64,
mode: u16,
uid: u32,
gid: u32,
) -> Result<u32> {
let (parent_id, name) = self.resolve_create_target(path)?;
let w = self
.writer
.as_mut()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: volume is read-only".into()))?;
let cnid = w.next_cnid;
w.next_cnid = w
.next_cnid
.checked_add(1)
.ok_or_else(|| crate::Error::Unsupported("hfs+: CNID space exhausted".into()))?;
let fork = writer::stream_data_to_blocks(w, dev, src, len, cnid)?;
writer::insert_file(
w,
parent_id,
&name,
cnid,
mode,
uid,
gid,
*b"\0\0\0\0",
*b"\0\0\0\0",
&fork,
false,
)?;
Ok(cnid)
}
pub fn create_symlink(
&mut self,
dev: &mut dyn BlockDevice,
path: &str,
target: &str,
mode: u16,
uid: u32,
gid: u32,
) -> Result<u32> {
let (parent_id, name) = self.resolve_create_target(path)?;
let w = self
.writer
.as_mut()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: volume is read-only".into()))?;
let cnid = w.next_cnid;
w.next_cnid = w
.next_cnid
.checked_add(1)
.ok_or_else(|| crate::Error::Unsupported("hfs+: CNID space exhausted".into()))?;
let fork = writer::write_inline_data(w, dev, target.as_bytes())?;
writer::insert_file(
w, parent_id, &name, cnid, mode, uid, gid, *b"slnk", *b"rhap", &fork, true,
)?;
Ok(cnid)
}
pub fn create_hardlink(
&mut self,
_dev: &mut dyn BlockDevice,
src_path: &str,
dst_path: &str,
) -> Result<u32> {
let (src_parent, src_name) = self.resolve_create_target(src_path)?;
let (dst_parent, dst_name) = self.resolve_create_target(dst_path)?;
let w = self
.writer
.as_mut()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: volume is read-only".into()))?;
writer::promote_to_hardlink(w, src_parent, &src_name, dst_parent, &dst_name)
}
pub fn remove(&mut self, _dev: &mut dyn BlockDevice, path: &str) -> Result<()> {
let (parent_id, name) = self.resolve_create_target(path)?;
let w = self
.writer
.as_mut()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: volume is read-only".into()))?;
writer::remove_entry(w, parent_id, &name)
}
#[doc(hidden)]
#[cfg(test)]
pub fn test_writer(&self) -> Option<&writer::Writer> {
self.writer.as_ref()
}
pub fn flush(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
let Some(w) = self.writer.as_mut() else {
return Ok(());
};
writer::flush(w, &mut self.volume_header, dev)?;
let case_sensitive = self.volume_header.is_hfsx();
let cat_fork = ForkReader::from_inline(
&self.volume_header.catalog_file,
self.volume_header.block_size,
"catalog",
)?;
self.catalog = Catalog::open(dev, cat_fork, case_sensitive)?;
w.flushed = false;
Ok(())
}
fn resolve_create_target(&self, path: &str) -> Result<(u32, UniStr)> {
let parts = split_path(path);
if parts.is_empty() {
return Err(crate::Error::InvalidArgument(
"hfs+: cannot create at the root path".into(),
));
}
let (last, prefix) = parts.split_last().unwrap();
let mut cnid = ROOT_FOLDER_ID;
let w = self
.writer
.as_ref()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: volume is read-only".into()))?;
for part in prefix {
let name = UniStr::from_str_lossy(part);
let (_, child_cnid, rec_type) = w.lookup(cnid, &name).ok_or_else(|| {
crate::Error::InvalidArgument(format!(
"hfs+: parent component {part:?} does not exist"
))
})?;
if rec_type != catalog::REC_FOLDER {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: component {part:?} is not a directory"
)));
}
cnid = child_cnid;
}
Ok((cnid, UniStr::from_str_lossy(last)))
}
pub fn total_bytes(&self) -> u64 {
u64::from(self.volume_header.total_blocks) * u64::from(self.volume_header.block_size)
}
pub fn block_size(&self) -> u32 {
self.volume_header.block_size
}
pub fn volume_name(&self) -> &str {
&self.volume_name
}
pub fn list_path(
&self,
dev: &mut dyn BlockDevice,
path: &str,
) -> Result<Vec<crate::fs::DirEntry>> {
let cnid = self.resolve_dir(dev, path)?;
self.list_cnid(dev, cnid)
}
pub fn open_file_reader<'a>(
&self,
dev: &'a mut dyn BlockDevice,
path: &str,
) -> Result<HfsPlusFileReader<'a>> {
let rec = self.lookup_path(dev, path)?;
let file = match rec {
CatalogRecord::File(f) => f,
CatalogRecord::Folder(_) => {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {path:?} is a directory, not a file"
)));
}
CatalogRecord::Thread(_) => {
return Err(crate::Error::InvalidImage(format!(
"hfs+: path {path:?} resolved to a thread record"
)));
}
};
let file = self.resolve_hard_link(dev, file)?;
let mode_bits = file.bsd.file_mode & mode::S_IFMT;
if file.is_symlink() || mode_bits == mode::S_IFLNK {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {path:?} is a symlink; use read_symlink_target_path"
)));
}
if mode_bits != 0 && mode_bits != mode::S_IFREG {
return Err(crate::Error::Unsupported(format!(
"hfs+: {path:?} is not a regular file (mode {:#06o})",
file.bsd.file_mode
)));
}
if file.bsd.owner_flags & decmpfs::UF_COMPRESSED != 0 {
let bytes = self.read_decmpfs_file(dev, &file, path)?;
return Ok(HfsPlusFileReader::buffered(bytes));
}
let fork = self.open_data_fork(dev, &file)?;
Ok(HfsPlusFileReader::streaming(
dev,
fork,
file.data_fork.logical_size,
))
}
pub fn read_symlink_target_path(
&self,
dev: &mut dyn BlockDevice,
path: &str,
) -> Result<String> {
let rec = self.lookup_path(dev, path)?;
let file = match rec {
CatalogRecord::File(f) => f,
_ => {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {path:?} is not a file (cannot be a symlink)"
)));
}
};
let file = self.resolve_hard_link(dev, file)?;
self.read_symlink_target_inner(dev, &file, path)
}
pub fn read_symlink_target(&self, dev: &mut dyn BlockDevice, cnid: u32) -> Result<String> {
let file = self.lookup_file_by_cnid(dev, cnid)?;
let file = self.resolve_hard_link(dev, file)?;
self.read_symlink_target_inner(dev, &file, &format!("CNID {cnid}"))
}
pub fn file_size(&self, dev: &mut dyn BlockDevice, path: &str) -> Result<u64> {
let rec = self.lookup_path(dev, path)?;
let file = match rec {
CatalogRecord::File(f) => f,
_ => {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {path:?} is not a file"
)));
}
};
let file = self.resolve_hard_link(dev, file)?;
Ok(file.data_fork.logical_size)
}
fn resolve_dir(&self, dev: &mut dyn BlockDevice, path: &str) -> Result<u32> {
let parts = split_path(path);
let mut cnid = ROOT_FOLDER_ID;
for part in parts {
let rec = self.lookup_component(dev, cnid, part)?;
match rec {
CatalogRecord::Folder(f) => {
cnid = f.folder_id;
}
CatalogRecord::File(_) => {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {part:?} is not a directory (in {path:?})"
)));
}
CatalogRecord::Thread(_) => {
return Err(crate::Error::InvalidImage(format!(
"hfs+: lookup of {part:?} returned a thread record"
)));
}
}
}
Ok(cnid)
}
fn lookup_path(&self, dev: &mut dyn BlockDevice, path: &str) -> Result<CatalogRecord> {
let parts = split_path(path);
if parts.is_empty() {
return Err(crate::Error::InvalidArgument(
"hfs+: cannot resolve root \"/\" as a leaf entry".into(),
));
}
let (last, prefix) = parts.split_last().unwrap();
let mut cnid = ROOT_FOLDER_ID;
for part in prefix {
let rec = self.lookup_component(dev, cnid, part)?;
match rec {
CatalogRecord::Folder(f) => cnid = f.folder_id,
_ => {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {part:?} is not a directory (in {path:?})"
)));
}
}
}
self.lookup_component(dev, cnid, last)
}
fn lookup_component(
&self,
dev: &mut dyn BlockDevice,
parent_id: u32,
name: &str,
) -> Result<CatalogRecord> {
let key = CatalogKey {
parent_id,
name: UniStr::from_str_lossy(name),
encoded_len: 0,
};
self.catalog.lookup(dev, &key)?.ok_or_else(|| {
crate::Error::InvalidArgument(format!(
"hfs+: no such entry {name:?} under CNID {parent_id}"
))
})
}
fn lookup_file_by_cnid(&self, dev: &mut dyn BlockDevice, cnid: u32) -> Result<CatalogFile> {
let thread_key = CatalogKey {
parent_id: cnid,
name: UniStr::default(),
encoded_len: 0,
};
let (parent_id, name) = match self.catalog.lookup(dev, &thread_key)? {
Some(CatalogRecord::Thread(t)) => (t.parent_id, t.name),
Some(_) => {
return Err(crate::Error::InvalidImage(format!(
"hfs+: CNID {cnid} thread key did not return a thread record"
)));
}
None => {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: no thread record for CNID {cnid}"
)));
}
};
let child_key = CatalogKey {
parent_id,
name,
encoded_len: 0,
};
match self.catalog.lookup(dev, &child_key)? {
Some(CatalogRecord::File(f)) => Ok(f),
Some(CatalogRecord::Folder(_)) => Err(crate::Error::InvalidArgument(format!(
"hfs+: CNID {cnid} names a folder, not a file"
))),
Some(CatalogRecord::Thread(_)) => Err(crate::Error::InvalidImage(format!(
"hfs+: CNID {cnid} reverse-lookup returned a thread record"
))),
None => Err(crate::Error::InvalidArgument(format!(
"hfs+: no catalog file for CNID {cnid}"
))),
}
}
fn resolve_hard_link(
&self,
dev: &mut dyn BlockDevice,
file: CatalogFile,
) -> Result<CatalogFile> {
if !file.is_hard_link() {
return Ok(file);
}
let private_dir = match self.private_data_dir(dev)? {
Some(cnid) => cnid,
None => {
return Err(crate::Error::InvalidImage(
"hfs+: hard-link record but no '\\0\\0\\0\\0HFS+ Private Data' \
directory found"
.into(),
));
}
};
let inode_id = file.bsd.special;
let name = format!("iNode{inode_id}");
let rec = self.lookup_component(dev, private_dir, &name)?;
match rec {
CatalogRecord::File(f) => {
if f.is_hard_link() {
return Err(crate::Error::InvalidImage(format!(
"hfs+: iNode{inode_id} is itself a hard-link indirection"
)));
}
Ok(f)
}
_ => Err(crate::Error::InvalidImage(format!(
"hfs+: iNode{inode_id} is not a regular file record"
))),
}
}
fn private_data_dir(&self, dev: &mut dyn BlockDevice) -> Result<Option<u32>> {
if self.private_dir_resolved.get() {
return Ok(self.private_dir_cnid.get());
}
let mut code_units: Vec<u16> = vec![0, 0, 0, 0];
code_units.extend("HFS+ Private Data".encode_utf16());
let key = CatalogKey {
parent_id: ROOT_FOLDER_ID,
name: UniStr { code_units },
encoded_len: 0,
};
let cnid = match self.catalog.lookup(dev, &key)? {
Some(CatalogRecord::Folder(f)) => Some(f.folder_id),
_ => None,
};
self.private_dir_cnid.set(cnid);
self.private_dir_resolved.set(true);
Ok(cnid)
}
fn open_data_fork(&self, dev: &mut dyn BlockDevice, file: &CatalogFile) -> Result<ForkReader> {
self.open_fork(
dev,
&file.data_fork,
file.file_id,
extents::FORK_DATA,
"data",
)
}
fn open_fork(
&self,
dev: &mut dyn BlockDevice,
fork: &ForkData,
file_id: u32,
fork_type: u8,
what: &str,
) -> Result<ForkReader> {
if u64::from(fork.total_blocks) <= fork.inline_blocks() {
return ForkReader::from_inline(fork, self.volume_header.block_size, what);
}
let overflow = self.overflow.as_ref().ok_or_else(|| {
crate::Error::InvalidImage(
"hfs+: fork needs overflow extents but volume has no \
extents-overflow file"
.into(),
)
})?;
let first_overflow_block = u32::try_from(fork.inline_blocks()).map_err(|_| {
crate::Error::InvalidImage("hfs+: inline fork block count overflows u32".into())
})?;
let extra = overflow.find_extents(dev, file_id, fork_type, first_overflow_block)?;
ForkReader::from_inline_plus_overflow(fork, &extra, self.volume_header.block_size, what)
}
fn read_decmpfs_file(
&self,
dev: &mut dyn BlockDevice,
file: &CatalogFile,
path: &str,
) -> Result<Vec<u8>> {
let attrs = self.attributes.as_ref().ok_or_else(|| {
crate::Error::InvalidImage(format!(
"hfs+: {path:?} has UF_COMPRESSED set but volume has no \
attributes file to hold com.apple.decmpfs"
))
})?;
let rec = attrs
.lookup(dev, file.file_id, "com.apple.decmpfs")?
.ok_or_else(|| {
crate::Error::InvalidImage(format!(
"hfs+: {path:?} has UF_COMPRESSED set but no com.apple.decmpfs \
attribute exists for CNID {}",
file.file_id
))
})?;
let xattr_bytes = match rec {
attributes::AttrRecord::Inline { data } => data,
attributes::AttrRecord::Fork { fork } => {
let what = "decmpfs-xattr-fork";
let reader = if u64::from(fork.total_blocks) <= fork.inline_blocks() {
ForkReader::from_inline(&fork, self.volume_header.block_size, what)?
} else {
return Err(crate::Error::Unsupported(
"hfs+: decmpfs xattr stored in a fork that needs overflow extents".into(),
));
};
let mut buf = vec![0u8; fork.logical_size as usize];
reader.read(dev, 0, &mut buf)?;
buf
}
};
if xattr_bytes.len() < decmpfs::DecmpfsHeader::SIZE {
return Err(crate::Error::InvalidImage(format!(
"hfs+: {path:?} com.apple.decmpfs is {} bytes, shorter than the 16-byte header",
xattr_bytes.len()
)));
}
let header = decmpfs::DecmpfsHeader::decode(&xattr_bytes)?;
if header.compression_type.is_resource_fork() {
if file.resource_fork.logical_size == 0 {
return Err(crate::Error::InvalidImage(format!(
"hfs+: {path:?} decmpfs claims compression type {:?} but resource fork is empty",
header.compression_type
)));
}
let rf_reader = self.open_fork(
dev,
&file.resource_fork,
file.file_id,
extents::FORK_RESOURCE,
"resource",
)?;
let mut rf_bytes = vec![0u8; file.resource_fork.logical_size as usize];
rf_reader.read(dev, 0, &mut rf_bytes)?;
decmpfs::decompress_resource_fork(
header.compression_type,
&rf_bytes,
header.uncompressed_size,
)
} else {
decmpfs::decompress_inline(
header.compression_type,
&xattr_bytes[decmpfs::DecmpfsHeader::SIZE..],
header.uncompressed_size,
)
}
}
fn read_symlink_target_inner(
&self,
dev: &mut dyn BlockDevice,
file: &CatalogFile,
descriptor: &str,
) -> Result<String> {
let mode_bits = file.bsd.file_mode & mode::S_IFMT;
let by_mode = mode_bits == mode::S_IFLNK;
let by_finder = file.is_symlink();
if !by_mode && !by_finder {
return Err(crate::Error::InvalidArgument(format!(
"hfs+: {descriptor} is not a symlink (mode {:#06o}, \
FileInfo type {:?}, creator {:?})",
file.bsd.file_mode,
bytes_to_osstr(&file.file_type),
bytes_to_osstr(&file.creator),
)));
}
let len = file.data_fork.logical_size;
if len == 0 {
return Ok(String::new());
}
if len > SYMLINK_MAX_BYTES {
return Err(crate::Error::InvalidImage(format!(
"hfs+: {descriptor} symlink target too large ({len} bytes, \
max {SYMLINK_MAX_BYTES})"
)));
}
let fork = self.open_data_fork(dev, file)?;
let mut buf = vec![0u8; len as usize];
fork.read(dev, 0, &mut buf)?;
if buf.last() == Some(&0) {
buf.pop();
}
String::from_utf8(buf).map_err(|e| {
crate::Error::InvalidImage(format!(
"hfs+: {descriptor} symlink target is not valid UTF-8: {e}"
))
})
}
fn list_cnid(&self, dev: &mut dyn BlockDevice, cnid: u32) -> Result<Vec<crate::fs::DirEntry>> {
use crate::fs::{DirEntry as FsDirEntry, EntryKind};
let mut out = Vec::new();
let node_size = u32::from(self.catalog.header.node_size);
let mut node_idx = self.catalog.header.first_leaf_node;
while node_idx != 0 {
let node = btree::read_node(dev, &self.catalog.fork, node_idx, node_size)?;
let desc = btree::NodeDescriptor::decode(&node)?;
if desc.kind != btree::KIND_LEAF {
return Err(crate::Error::InvalidImage(format!(
"hfs+: leaf chain node {node_idx} has non-leaf kind {}",
desc.kind
)));
}
let offs = btree::record_offsets(&node, desc.num_records)?;
let mut passed_parent = false;
for i in 0..desc.num_records as usize {
let rec = btree::record_bytes(&node, &offs, i);
let key = CatalogKey::decode(rec)?;
use std::cmp::Ordering as O;
match key.parent_id.cmp(&cnid) {
O::Less => continue,
O::Greater => {
passed_parent = true;
break;
}
O::Equal => {}
}
let body_start = align2(key.encoded_len);
if body_start > rec.len() {
return Err(crate::Error::InvalidImage(
"hfs+: catalog key overruns record".into(),
));
}
let body = &rec[body_start..];
let record = CatalogRecord::decode(body)?;
let (kind, child_id) = match &record {
CatalogRecord::Folder(f) => (EntryKind::Dir, f.folder_id),
CatalogRecord::File(f) => {
if f.is_hard_link() {
let resolved_kind = self
.resolve_hard_link(dev, f.clone())
.map(|r| file_kind(&r))
.unwrap_or(EntryKind::Unknown);
(resolved_kind, f.file_id)
} else if f.is_symlink() {
(EntryKind::Symlink, f.file_id)
} else {
(file_kind(f), f.file_id)
}
}
CatalogRecord::Thread(_) => continue,
};
let size = match &record {
CatalogRecord::File(f) if matches!(kind, EntryKind::Regular) => {
f.data_fork.logical_size
}
_ => 0,
};
out.push(FsDirEntry {
name: key.name.to_string_lossy(),
inode: child_id,
kind,
size,
});
}
if passed_parent {
break;
}
node_idx = desc.f_link;
}
Ok(out)
}
}
pub struct HfsPlusFileReader<'a> {
inner: HfsPlusFileReaderInner<'a>,
}
enum HfsPlusFileReaderInner<'a> {
Streaming {
dev: &'a mut dyn BlockDevice,
fork: ForkReader,
remaining: u64,
position: u64,
},
Buffered {
bytes: Vec<u8>,
position: u64,
_phantom: std::marker::PhantomData<&'a ()>,
},
}
impl<'a> HfsPlusFileReader<'a> {
pub(crate) fn streaming(
dev: &'a mut dyn BlockDevice,
fork: ForkReader,
logical_size: u64,
) -> Self {
Self {
inner: HfsPlusFileReaderInner::Streaming {
dev,
fork,
remaining: logical_size,
position: 0,
},
}
}
pub(crate) fn buffered(bytes: Vec<u8>) -> Self {
Self {
inner: HfsPlusFileReaderInner::Buffered {
bytes,
position: 0,
_phantom: std::marker::PhantomData,
},
}
}
}
impl<'a> Read for HfsPlusFileReader<'a> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match &mut self.inner {
HfsPlusFileReaderInner::Streaming {
dev,
fork,
remaining,
position,
} => {
if *remaining == 0 || buf.is_empty() {
return Ok(0);
}
let want = (buf.len() as u64).min(*remaining) as usize;
fork.read(*dev, *position, &mut buf[..want])
.map_err(std::io::Error::other)?;
*position += want as u64;
*remaining -= want as u64;
Ok(want)
}
HfsPlusFileReaderInner::Buffered {
bytes, position, ..
} => {
let len = bytes.len() as u64;
if *position >= len || buf.is_empty() {
return Ok(0);
}
let avail = (len - *position) as usize;
let want = buf.len().min(avail);
let p = *position as usize;
buf[..want].copy_from_slice(&bytes[p..p + want]);
*position += want as u64;
Ok(want)
}
}
}
}
impl<'a> std::io::Seek for HfsPlusFileReader<'a> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
match &mut self.inner {
HfsPlusFileReaderInner::Streaming {
fork,
remaining,
position,
..
} => {
let total = fork.logical_size as i128;
let new = match pos {
std::io::SeekFrom::Start(n) => n as i128,
std::io::SeekFrom::Current(d) => *position as i128 + d as i128,
std::io::SeekFrom::End(d) => total + d as i128,
};
if new < 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"hfs+: seek to negative offset",
));
}
*position = new as u64;
*remaining = fork.logical_size.saturating_sub(*position);
Ok(*position)
}
HfsPlusFileReaderInner::Buffered {
bytes, position, ..
} => {
let total = bytes.len() as i128;
let new = match pos {
std::io::SeekFrom::Start(n) => n as i128,
std::io::SeekFrom::Current(d) => *position as i128 + d as i128,
std::io::SeekFrom::End(d) => total + d as i128,
};
if new < 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"hfs+: seek to negative offset",
));
}
*position = new as u64;
Ok(*position)
}
}
}
}
impl<'a> crate::fs::FileReadHandle for HfsPlusFileReader<'a> {
fn len(&self) -> u64 {
match &self.inner {
HfsPlusFileReaderInner::Streaming { fork, .. } => fork.logical_size,
HfsPlusFileReaderInner::Buffered { bytes, .. } => bytes.len() as u64,
}
}
}
pub fn probe(dev: &mut dyn BlockDevice) -> Result<bool> {
if dev.total_size() < 1024 + 2 {
return Ok(false);
}
let mut sig = [0u8; 2];
dev.read_at(1024, &mut sig)?;
Ok(&sig == b"H+" || &sig == b"HX")
}
fn file_kind(f: &CatalogFile) -> crate::fs::EntryKind {
use crate::fs::EntryKind;
match f.bsd.file_mode & mode::S_IFMT {
mode::S_IFREG => EntryKind::Regular,
mode::S_IFDIR => EntryKind::Dir, mode::S_IFLNK => EntryKind::Symlink,
mode::S_IFCHR => EntryKind::Char,
mode::S_IFBLK => EntryKind::Block,
mode::S_IFIFO => EntryKind::Fifo,
mode::S_IFSOCK => EntryKind::Socket,
0 => EntryKind::Regular,
_ => EntryKind::Unknown,
}
}
fn lookup_thread_name(
dev: &mut dyn BlockDevice,
catalog: &Catalog,
cnid: u32,
) -> Result<Option<String>> {
let key = CatalogKey {
parent_id: cnid,
name: UniStr::default(),
encoded_len: 0,
};
match catalog.lookup(dev, &key)? {
Some(CatalogRecord::Thread(t)) => Ok(Some(t.name.to_string_lossy())),
_ => Ok(None),
}
}
fn open_attributes(
dev: &mut dyn BlockDevice,
vh: &VolumeHeader,
case_sensitive: bool,
) -> Result<Option<Attributes>> {
if vh.attributes_file.total_blocks == 0 {
return Ok(None);
}
let fork = ForkReader::from_inline(&vh.attributes_file, vh.block_size, "attributes")?;
Ok(Some(Attributes::open(dev, fork, case_sensitive)?))
}
fn bytes_to_osstr(b: &[u8; 4]) -> String {
if b.iter().all(|&c| (0x20..=0x7E).contains(&c)) {
format!("'{}'", String::from_utf8_lossy(b))
} else {
format!("0x{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3])
}
}
fn align2(n: usize) -> usize {
n + (n & 1)
}
fn split_path(path: &str) -> Vec<&str> {
path.split('/')
.filter(|p| !p.is_empty() && *p != ".")
.collect()
}
impl crate::fs::FilesystemFactory for HfsPlus {
type FormatOpts = writer::FormatOpts;
fn format(dev: &mut dyn BlockDevice, opts: &Self::FormatOpts) -> Result<Self> {
Self::format(dev, opts)
}
fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
Self::open(dev)
}
}
impl crate::fs::Filesystem for HfsPlus {
fn create_file(
&mut self,
dev: &mut dyn BlockDevice,
path: &std::path::Path,
src: crate::fs::FileSource,
meta: crate::fs::FileMeta,
) -> Result<()> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
let len = src.len()?;
let (mut reader, _) = src.open()?;
let mode = meta.mode;
self.create_file(dev, s, &mut reader, len, mode, meta.uid, meta.gid)
.map(|_| ())
}
fn create_dir(
&mut self,
dev: &mut dyn BlockDevice,
path: &std::path::Path,
meta: crate::fs::FileMeta,
) -> Result<()> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
let mode = meta.mode;
self.create_dir(dev, s, mode, meta.uid, meta.gid)
.map(|_| ())
}
fn create_symlink(
&mut self,
dev: &mut dyn BlockDevice,
path: &std::path::Path,
target: &std::path::Path,
meta: crate::fs::FileMeta,
) -> Result<()> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
let t = target.to_str().ok_or_else(|| {
crate::Error::InvalidArgument("hfs+: non-UTF-8 symlink target".into())
})?;
let mode = meta.mode;
self.create_symlink(dev, s, t, mode, meta.uid, meta.gid)
.map(|_| ())
}
fn create_device(
&mut self,
_dev: &mut dyn BlockDevice,
_path: &std::path::Path,
_kind: crate::fs::DeviceKind,
_major: u32,
_minor: u32,
_meta: crate::fs::FileMeta,
) -> Result<()> {
Err(crate::Error::Unsupported(
"hfs+: device / FIFO / socket nodes are not yet implemented".into(),
))
}
fn remove(&mut self, dev: &mut dyn BlockDevice, path: &std::path::Path) -> Result<()> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
self.remove(dev, s)
}
fn list(
&mut self,
dev: &mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<Vec<crate::fs::DirEntry>> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
self.list_path(dev, s)
}
fn read_file<'a>(
&'a mut self,
dev: &'a mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<Box<dyn std::io::Read + 'a>> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
let r = self.open_file_reader(dev, s)?;
Ok(Box::new(r))
}
fn open_file_ro<'a>(
&'a mut self,
dev: &'a mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<Box<dyn crate::fs::FileReadHandle + 'a>> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
let r = self.open_file_reader(dev, s)?;
Ok(Box::new(r))
}
fn open_file_rw<'a>(
&'a mut self,
dev: &'a mut dyn BlockDevice,
path: &std::path::Path,
flags: crate::fs::OpenFlags,
meta: Option<crate::fs::FileMeta>,
) -> Result<Box<dyn crate::fs::FileHandle + 'a>> {
handle::open_file_rw(self, dev, path, flags, meta)
}
fn flush(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
Self::flush(self, dev)
}
fn read_symlink(
&mut self,
dev: &mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<std::path::PathBuf> {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("hfs+: non-UTF-8 path".into()))?;
Ok(std::path::PathBuf::from(
self.read_symlink_target_path(dev, s)?,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block::MemoryBackend;
#[test]
fn probe_recognises_h_plus_signature() {
let mut dev = MemoryBackend::new(8192);
dev.write_at(1024, b"H+").unwrap();
assert!(probe(&mut dev).unwrap());
}
#[test]
fn probe_recognises_hx_signature() {
let mut dev = MemoryBackend::new(8192);
dev.write_at(1024, b"HX").unwrap();
assert!(probe(&mut dev).unwrap());
}
#[test]
fn probe_rejects_unknown_signature() {
let mut dev = MemoryBackend::new(8192);
dev.write_at(1024, b"NO").unwrap();
assert!(!probe(&mut dev).unwrap());
}
#[test]
fn open_fails_on_garbage() {
let mut dev = MemoryBackend::new(8192);
assert!(HfsPlus::open(&mut dev).is_err());
}
#[test]
fn create_hardlink_returns_inode_cnid_matching_resolved_file_id() {
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let data = b"link-inode invariant payload\n".repeat(8);
let mut src = std::io::Cursor::new(&data);
hfs.create_file(&mut dev, "/src", &mut src, data.len() as u64, 0o644, 0, 0)
.unwrap();
let link_inode = hfs.create_hardlink(&mut dev, "/src", "/dst").unwrap();
hfs.flush(&mut dev).unwrap();
let hfs = HfsPlus::open(&mut dev).unwrap();
let inode_file = hfs.lookup_file_by_cnid(&mut dev, link_inode).unwrap();
assert_eq!(
inode_file.file_id, link_inode,
"iNode's catalog file_id must equal the link-inode CNID"
);
assert!(
!inode_file.is_hard_link(),
"iNode itself must not be an hlnk record (would recurse)"
);
assert_eq!(
inode_file.data_fork.logical_size,
data.len() as u64,
"iNode owns the data fork; logical_size must equal payload"
);
assert_eq!(
inode_file.bsd.special, 2,
"iNode.bsd.special is the link count (2 = src + dst)"
);
for path in ["/src", "/dst"] {
let rec = hfs.lookup_path(&mut dev, path).unwrap();
let f = match rec {
catalog::CatalogRecord::File(f) => f,
_ => panic!("{path:?} should be a catalog file record"),
};
assert!(f.is_hard_link(), "{path:?} must be an hlnk record");
assert_eq!(
f.bsd.special, link_inode,
"{path:?} bsd.special must point at the iNode CNID"
);
assert_ne!(
f.file_id, link_inode,
"{path:?} hlnk file_id must differ from the iNode CNID"
);
assert_eq!(
f.data_fork.logical_size, 0,
"{path:?} hlnk record's own data fork must be empty"
);
}
}
#[test]
fn reopen_writable_round_trip_add_file() {
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let first = b"first file from format() pass\n".repeat(4);
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&first);
hfs.create_file(
&mut dev,
"/first.txt",
&mut src,
first.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let second = b"second file added after reopen\n".repeat(7);
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let cap = <HfsPlus as crate::fs::Filesystem>::mutation_capability(&hfs);
assert_eq!(
cap,
crate::fs::MutationCapability::Mutable,
"freshly-opened HFS+ must advertise Mutable"
);
let mut r = hfs.open_file_reader(&mut dev, "/first.txt").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut r, &mut buf).unwrap();
assert_eq!(buf, first, "existing file content survives reopen");
drop(r);
let mut src = std::io::Cursor::new(&second);
hfs.create_file(
&mut dev,
"/second.txt",
&mut src,
second.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let hfs = HfsPlus::open(&mut dev).unwrap();
let entries = hfs.list_path(&mut dev, "/").unwrap();
let names: std::collections::BTreeSet<&str> =
entries.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains("first.txt"),
"first.txt missing after reopen; got {names:?}"
);
assert!(
names.contains("second.txt"),
"second.txt missing after reopen; got {names:?}"
);
let size = hfs.file_size(&mut dev, "/second.txt").unwrap();
assert_eq!(size, second.len() as u64);
let mut r = hfs.open_file_reader(&mut dev, "/second.txt").unwrap();
let mut got = Vec::new();
std::io::Read::read_to_end(&mut r, &mut got).unwrap();
assert_eq!(got, second, "second.txt bytes survive a second reopen");
let mut r = hfs.open_file_reader(&mut dev, "/first.txt").unwrap();
let mut got = Vec::new();
std::io::Read::read_to_end(&mut r, &mut got).unwrap();
assert_eq!(got, first, "first.txt bytes still intact");
}
#[test]
fn reopen_writable_round_trip_remove_file() {
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let payload = b"goodbye, cruel world\n".repeat(16);
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&payload);
hfs.create_file(
&mut dev,
"/doomed.txt",
&mut src,
payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.create_file(
&mut dev,
"/keeper.txt",
&mut std::io::Cursor::new(b"keep me\n"),
8,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let free_before;
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
free_before = hfs.volume_header.free_blocks;
hfs.remove(&mut dev, "/doomed.txt").unwrap();
hfs.flush(&mut dev).unwrap();
}
let hfs = HfsPlus::open(&mut dev).unwrap();
let entries = hfs.list_path(&mut dev, "/").unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(
!names.contains(&"doomed.txt"),
"doomed.txt should be gone after remove + reopen, got {names:?}"
);
assert!(
names.contains(&"keeper.txt"),
"keeper.txt should still exist, got {names:?}"
);
let bs = hfs.block_size() as u64;
let freed_blocks = (payload.len() as u64).div_ceil(bs) as u32;
assert!(
hfs.volume_header.free_blocks >= free_before + freed_blocks,
"expected at least {freed_blocks} more free blocks after remove \
(before={free_before}, after={})",
hfs.volume_header.free_blocks
);
}
#[test]
fn open_file_rw_round_trip_non_journaled() {
use crate::fs::{Filesystem, OpenFlags};
use std::io::{Read, Seek, SeekFrom, Write};
let mut dev = MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let payload: Vec<u8> = (0..16 * 1024).map(|i| (i & 0xFF) as u8).collect();
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&payload);
hfs.create_file(
&mut dev,
"/edit.bin",
&mut src,
payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let mut h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/edit.bin"),
OpenFlags::default(),
None,
)
.unwrap();
assert_eq!(h.len(), payload.len() as u64);
h.seek(SeekFrom::Start(4096)).unwrap();
h.write_all(b"PATCHED_RANGE").unwrap();
h.sync().unwrap();
drop(h);
}
{
let hfs = HfsPlus::open(&mut dev).unwrap();
let mut r = hfs.open_file_reader(&mut dev, "/edit.bin").unwrap();
let mut got = Vec::new();
r.read_to_end(&mut got).unwrap();
assert_eq!(got.len(), payload.len());
assert_eq!(&got[..4096], &payload[..4096], "head unchanged");
assert_eq!(&got[4096..4096 + 13], b"PATCHED_RANGE", "patch is present");
assert_eq!(&got[4096 + 13..], &payload[4096 + 13..], "tail unchanged");
}
}
#[test]
fn open_file_rw_round_trip_journaled() {
use crate::fs::{Filesystem, OpenFlags};
use std::io::{Read, Seek, SeekFrom, Write};
let mut dev = MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts {
journaled: true,
..writer::FormatOpts::default()
};
let payload: Vec<u8> = (0..8 * 1024).map(|i| (i & 0xFF) as u8).collect();
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&payload);
hfs.create_file(
&mut dev,
"/jrnl.bin",
&mut src,
payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
assert_ne!(
hfs.volume_header.attributes & writer::VOL_ATTR_JOURNALED,
0,
"journaled bit must survive reopen"
);
let mut h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/jrnl.bin"),
OpenFlags::default(),
None,
)
.unwrap();
h.seek(SeekFrom::Start(2048)).unwrap();
h.write_all(b"journal-bypass").unwrap();
h.sync().unwrap();
}
{
let hfs = HfsPlus::open(&mut dev).unwrap();
let mut r = hfs.open_file_reader(&mut dev, "/jrnl.bin").unwrap();
let mut got = Vec::new();
r.read_to_end(&mut got).unwrap();
assert_eq!(got.len(), payload.len());
assert_eq!(&got[..2048], &payload[..2048]);
assert_eq!(&got[2048..2048 + 14], b"journal-bypass");
assert_eq!(&got[2048 + 14..], &payload[2048 + 14..]);
}
let mut vh_buf = [0u8; 512];
dev.read_at(volume_header::VOLUME_HEADER_OFFSET, &mut vh_buf)
.unwrap();
let info_block = u32::from_be_bytes(vh_buf[12..16].try_into().unwrap());
let bs = u32::from_be_bytes(vh_buf[40..44].try_into().unwrap());
assert_ne!(info_block, 0);
let info_off = u64::from(info_block) * u64::from(bs);
let mut info = [0u8; 52];
dev.read_at(info_off, &mut info).unwrap();
let jbuf_off = u64::from_be_bytes(info[36..44].try_into().unwrap());
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let start = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
let end = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
assert_eq!(
start, end,
"Path A requires the journal to be sealed (start == end) \
after a successful sync (start={start:#x}, end={end:#x})"
);
}
#[test]
fn open_file_rw_replays_dirty_journal_on_open() {
use crate::fs::{Filesystem, OpenFlags};
use std::io::{Read, Seek, SeekFrom, Write};
let mut dev = MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts {
journaled: true,
..writer::FormatOpts::default()
};
let payload = b"original\n".repeat(64);
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&payload);
hfs.create_file(
&mut dev,
"/replay.bin",
&mut src,
payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let (jbuf_off, sealed_start, sealed_end) = {
let mut hfs = HfsPlus::open(&mut dev).unwrap();
{
let mut h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/replay.bin"),
OpenFlags::default(),
None,
)
.unwrap();
h.seek(SeekFrom::Start(64)).unwrap();
h.write_all(b"REPLAYED-DATA-FROM-JOURNAL").unwrap();
h.sync().unwrap();
}
let mut vh_buf = [0u8; 512];
dev.read_at(volume_header::VOLUME_HEADER_OFFSET, &mut vh_buf)
.unwrap();
let info_block = u32::from_be_bytes(vh_buf[12..16].try_into().unwrap());
let bs = u32::from_be_bytes(vh_buf[40..44].try_into().unwrap());
let info_off = u64::from(info_block) * u64::from(bs);
let mut info = [0u8; 52];
dev.read_at(info_off, &mut info).unwrap();
let jbuf_off = u64::from_be_bytes(info[36..44].try_into().unwrap());
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let s = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
let e = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
assert_eq!(s, e, "post-sync the journal must be clean");
(jbuf_off, s, e)
};
assert_eq!(sealed_start, sealed_end);
{
let hfs = HfsPlus::open(&mut dev).unwrap();
let mut got = Vec::new();
hfs.open_file_reader(&mut dev, "/replay.bin")
.unwrap()
.read_to_end(&mut got)
.unwrap();
assert_eq!(&got[64..64 + 26], b"REPLAYED-DATA-FROM-JOURNAL");
}
let rewound_start: u64 = u64::from(super::journal::JHDR_SIZE);
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
hdr[8..16].copy_from_slice(&rewound_start.to_be_bytes());
dev.write_at(jbuf_off, &hdr).unwrap();
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/replay.bin"),
OpenFlags::default(),
None,
)
.unwrap();
drop(h);
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let s = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
let e = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
assert_eq!(s, e, "replay must leave the journal clean");
}
}
#[test]
fn replay_on_open_restores_lost_user_data() {
use crate::fs::{Filesystem, OpenFlags};
use std::io::{Seek, SeekFrom, Write};
let mut dev = MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts {
journaled: true,
..writer::FormatOpts::default()
};
let payload = vec![0xAAu8; 4096];
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&payload);
hfs.create_file(
&mut dev,
"/x.bin",
&mut src,
payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let (jbuf_off, sealed_end) = {
let mut hfs = HfsPlus::open(&mut dev).unwrap();
{
let mut h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/x.bin"),
OpenFlags::default(),
None,
)
.unwrap();
h.seek(SeekFrom::Start(0)).unwrap();
h.write_all(&[0xBBu8; 4096]).unwrap();
h.sync().unwrap();
}
let mut vh_buf = [0u8; 512];
dev.read_at(volume_header::VOLUME_HEADER_OFFSET, &mut vh_buf)
.unwrap();
let info_block = u32::from_be_bytes(vh_buf[12..16].try_into().unwrap());
let bs = u32::from_be_bytes(vh_buf[40..44].try_into().unwrap());
let info_off = u64::from(info_block) * u64::from(bs);
let mut info = [0u8; 52];
dev.read_at(info_off, &mut info).unwrap();
let jbuf_off = u64::from_be_bytes(info[36..44].try_into().unwrap());
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let e = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
(jbuf_off, e)
};
let dev_off_block0 = {
let hfs = HfsPlus::open(&mut dev).unwrap();
let CatalogRecord::File(f) = hfs.lookup_path(&mut dev, "/x.bin").unwrap() else {
panic!("expected file");
};
let w = hfs.writer.as_ref().unwrap();
let body = w
.catalog
.get(&writer::OwnedKey {
parent_id: ROOT_FOLDER_ID,
name: catalog::UniStr::from_str_lossy("x.bin"),
})
.expect("catalog body");
let sb = u32::from_be_bytes(body[88 + 16..88 + 20].try_into().unwrap()); assert!(sb > 0, "file must have a real data block");
let _ = f;
u64::from(sb) * u64::from(hfs.volume_header.block_size)
};
dev.write_at(dev_off_block0, &[0u8; 4096]).unwrap();
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let rewound: u64 = u64::from(super::journal::JHDR_SIZE);
hdr[8..16].copy_from_slice(&rewound.to_be_bytes());
dev.write_at(jbuf_off, &hdr).unwrap();
let _ = sealed_end;
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/x.bin"),
OpenFlags::default(),
None,
)
.unwrap();
drop(h);
}
let mut got = [0u8; 4096];
dev.read_at(dev_off_block0, &mut got).unwrap();
assert!(
got.iter().all(|&b| b == 0xBB),
"replay must have restored the user data"
);
}
#[test]
fn open_file_rw_journaled_passes_fsck_hfsplus() {
use crate::fs::{Filesystem, OpenFlags};
use std::io::{Seek, SeekFrom, Write};
use std::process::Command;
let fsck = match Command::new("sh")
.arg("-c")
.arg("command -v fsck.hfsplus")
.output()
{
Ok(o) if o.status.success() && !o.stdout.is_empty() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => return, };
let tmp = tempfile::NamedTempFile::new().expect("tmp");
let path = tmp.path().to_path_buf();
let total: u64 = 8 * 1024 * 1024;
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
f.set_len(total).unwrap();
drop(f);
let mut dev = crate::block::FileBackend::open(&path).unwrap();
let opts = writer::FormatOpts {
journaled: true,
volume_name: "fsckTestVol".into(),
..writer::FormatOpts::default()
};
let payload: Vec<u8> = (0..16 * 1024).map(|i| (i & 0xFF) as u8).collect();
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&payload);
hfs.create_file(
&mut dev,
"/edit.bin",
&mut src,
payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let mut h = hfs
.open_file_rw(
&mut dev,
std::path::Path::new("/edit.bin"),
OpenFlags::default(),
None,
)
.unwrap();
h.seek(SeekFrom::Start(8192)).unwrap();
h.write_all(b"journal-test-payload").unwrap();
h.sync().unwrap();
}
crate::block::BlockDevice::sync(&mut dev).unwrap();
drop(dev);
let out = Command::new(&fsck)
.arg("-fn")
.arg(&path)
.output()
.expect("run fsck.hfsplus");
assert!(
out.status.success(),
"fsck.hfsplus failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
#[test]
fn flush_routes_metadata_through_journal_on_reopen() {
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts {
journaled: true,
..writer::FormatOpts::default()
};
let initial = b"initial-payload\n".repeat(16);
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&initial);
hfs.create_file(
&mut dev,
"/initial.bin",
&mut src,
initial.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let jbuf_off = {
let mut vh_buf = [0u8; 512];
dev.read_at(volume_header::VOLUME_HEADER_OFFSET, &mut vh_buf)
.unwrap();
let info_block = u32::from_be_bytes(vh_buf[12..16].try_into().unwrap());
let bs = u32::from_be_bytes(vh_buf[40..44].try_into().unwrap());
assert_ne!(info_block, 0, "journaled volume must have a JIB");
let info_off = u64::from(info_block) * u64::from(bs);
let mut info = [0u8; 52];
dev.read_at(info_off, &mut info).unwrap();
u64::from_be_bytes(info[36..44].try_into().unwrap())
};
let new_payload = b"second\n".repeat(32);
{
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let s = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
let e = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
assert_eq!(s, e, "fresh-format flush leaves the journal clean");
hfs.create_dir(&mut dev, "/added-dir", 0o755, 0, 0).unwrap();
let mut src = std::io::Cursor::new(&new_payload);
hfs.create_file(
&mut dev,
"/added-file.bin",
&mut src,
new_payload.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.remove(&mut dev, "/initial.bin").unwrap();
hfs.flush(&mut dev).unwrap();
}
let sealed_end = {
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let s = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
let e = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
assert_eq!(
s, e,
"Path A for flush requires the journal to seal post-commit"
);
e
};
assert!(
sealed_end > u64::from(journal::JHDR_SIZE),
"the post-mutation flush must have advanced the journal end \
cursor past the initial JHDR_SIZE — got {sealed_end:#x}"
);
{
let hfs = HfsPlus::open(&mut dev).unwrap();
let entries = hfs.list_path(&mut dev, "/").unwrap();
let names: std::collections::BTreeSet<&str> =
entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains("added-dir"));
assert!(names.contains("added-file.bin"));
assert!(
!names.contains("initial.bin"),
"removed file must be gone post-flush; got {names:?}"
);
}
let (cat_off, cat_len, bm_off, bm_len) = {
let hfs = HfsPlus::open(&mut dev).unwrap();
let bs = u64::from(hfs.volume_header.block_size);
let cat = hfs.volume_header.catalog_file.extents[0];
let bm = hfs.volume_header.allocation_file.extents[0];
(
u64::from(cat.start_block) * bs,
u64::from(cat.block_count) * bs,
u64::from(bm.start_block) * bs,
u64::from(bm.block_count) * bs,
)
};
let zeros_cat = vec![0u8; cat_len as usize];
dev.write_at(cat_off, &zeros_cat).unwrap();
let zeros_bm = vec![0u8; bm_len as usize];
dev.write_at(bm_off, &zeros_bm).unwrap();
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let rewound: u64 = u64::from(journal::JHDR_SIZE);
hdr[8..16].copy_from_slice(&rewound.to_be_bytes());
dev.write_at(jbuf_off, &hdr).unwrap();
{
let hfs = HfsPlus::open(&mut dev).unwrap();
let entries = hfs.list_path(&mut dev, "/").unwrap();
let names: std::collections::BTreeSet<&str> =
entries.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains("added-dir"),
"replay must restore the catalog so the dir is visible; \
got {names:?}"
);
assert!(
names.contains("added-file.bin"),
"replay must restore the catalog so the file is visible; \
got {names:?}"
);
assert!(
!names.contains("initial.bin"),
"removed file must not reappear after replay; got {names:?}"
);
let mut r = hfs.open_file_reader(&mut dev, "/added-file.bin").unwrap();
let mut got = Vec::new();
std::io::Read::read_to_end(&mut r, &mut got).unwrap();
assert_eq!(
got, new_payload,
"file data referenced by the replayed catalog must match"
);
let mut hdr = [0u8; 24];
dev.read_at(jbuf_off, &mut hdr).unwrap();
let s = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
let e = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
assert_eq!(s, e, "replay leaves the journal sealed");
}
}
#[test]
fn open_file_ro_random_seek_hfs_plus() {
use crate::fs::Filesystem;
use std::io::{Read, Seek, SeekFrom};
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let data: Vec<u8> = (0..6000u32).map(|i| (i & 0xFF) as u8).collect();
{
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut src = std::io::Cursor::new(&data);
hfs.create_file(
&mut dev,
"/ro.bin",
&mut src,
data.len() as u64,
0o644,
0,
0,
)
.unwrap();
hfs.flush(&mut dev).unwrap();
}
let mut hfs = HfsPlus::open(&mut dev).unwrap();
let mut h = hfs
.open_file_ro(&mut dev, std::path::Path::new("/ro.bin"))
.expect("open_file_ro");
assert_eq!(h.len(), data.len() as u64);
assert!(!h.is_empty());
h.seek(SeekFrom::Start(3333)).unwrap();
let mut buf = [0u8; 96];
h.read_exact(&mut buf).unwrap();
assert_eq!(&buf[..], &data[3333..3429]);
h.seek(SeekFrom::Start(40)).unwrap();
let mut buf2 = [0u8; 64];
h.read_exact(&mut buf2).unwrap();
assert_eq!(&buf2[..], &data[40..104]);
}
fn make_decmpfs_header(compression_type: u32, uncompressed_size: u64) -> [u8; 16] {
let mut hdr = [0u8; 16];
hdr[0..4].copy_from_slice(&decmpfs::DECMPFS_MAGIC.to_be_bytes());
hdr[4..8].copy_from_slice(&compression_type.to_le_bytes());
hdr[8..16].copy_from_slice(&uncompressed_size.to_le_bytes());
hdr
}
fn synth_attributes_btree(node_size: u32, file_id: u32, decmpfs_value: &[u8]) -> Vec<u8> {
use btree::{HEADER_REC_SIZE, KIND_HEADER, KIND_LEAF, NODE_DESCRIPTOR_SIZE};
let ns = node_size as usize;
let mut hdr = vec![0u8; ns];
hdr[8] = KIND_HEADER as u8;
hdr[9] = 0;
hdr[10..12].copy_from_slice(&3u16.to_be_bytes());
let h = NODE_DESCRIPTOR_SIZE;
hdr[h..h + 2].copy_from_slice(&1u16.to_be_bytes()); hdr[h + 2..h + 6].copy_from_slice(&1u32.to_be_bytes()); hdr[h + 6..h + 10].copy_from_slice(&1u32.to_be_bytes()); hdr[h + 10..h + 14].copy_from_slice(&1u32.to_be_bytes()); hdr[h + 14..h + 18].copy_from_slice(&1u32.to_be_bytes()); hdr[h + 18..h + 20].copy_from_slice(&(node_size as u16).to_be_bytes());
hdr[h + 20..h + 22].copy_from_slice(&264u16.to_be_bytes()); hdr[h + 22..h + 26].copy_from_slice(&2u32.to_be_bytes()); hdr[h + 26..h + 30].copy_from_slice(&0u32.to_be_bytes()); hdr[h + 32..h + 36].copy_from_slice(&node_size.to_be_bytes()); hdr[h + 36] = 0; hdr[h + 37] = 0xCF; hdr[h + 38..h + 42].copy_from_slice(&6u32.to_be_bytes()); let user_off = NODE_DESCRIPTOR_SIZE + HEADER_REC_SIZE;
let map_off = user_off + 128;
let offsets_table = 2 * 4;
let free_off = ns - offsets_table;
let offs = [
NODE_DESCRIPTOR_SIZE as u16,
user_off as u16,
map_off as u16,
free_off as u16,
];
for (i, &o) in offs.iter().enumerate() {
let pos = ns - 2 * (i + 1);
hdr[pos..pos + 2].copy_from_slice(&o.to_be_bytes());
}
hdr[map_off] = 0b1100_0000;
let name = "com.apple.decmpfs";
let name_units: Vec<u16> = name.encode_utf16().collect();
let name_len = name_units.len();
let mut key_buf = Vec::new();
let key_payload_len = 12 + 2 * name_len;
key_buf.extend_from_slice(&(key_payload_len as u16).to_be_bytes());
key_buf.extend_from_slice(&0u16.to_be_bytes()); key_buf.extend_from_slice(&file_id.to_be_bytes());
key_buf.extend_from_slice(&0u32.to_be_bytes()); key_buf.extend_from_slice(&(name_len as u16).to_be_bytes());
for u in &name_units {
key_buf.extend_from_slice(&u.to_be_bytes());
}
let mut body = Vec::new();
body.extend_from_slice(&attributes::REC_INLINE_DATA.to_be_bytes());
body.extend_from_slice(&0u32.to_be_bytes()); body.extend_from_slice(&(decmpfs_value.len() as u32).to_be_bytes());
body.extend_from_slice(decmpfs_value);
let rec = [key_buf.clone(), body.clone()].concat();
let mut leaf = vec![0u8; ns];
leaf[8] = KIND_LEAF as u8;
leaf[9] = 1; leaf[10..12].copy_from_slice(&1u16.to_be_bytes());
let start = NODE_DESCRIPTOR_SIZE;
let end = start + rec.len();
assert!(end <= ns - 4, "attribute record overruns node");
leaf[start..end].copy_from_slice(&rec);
let l_offs = [start as u16, end as u16];
for (i, &o) in l_offs.iter().enumerate() {
let pos = ns - 2 * (i + 1);
leaf[pos..pos + 2].copy_from_slice(&o.to_be_bytes());
}
let mut out = Vec::with_capacity(2 * ns);
out.extend_from_slice(&hdr);
out.extend_from_slice(&leaf);
out
}
fn find_root_file_body_mut<'a>(w: &'a mut writer::Writer, file_name: &str) -> &'a mut Vec<u8> {
let target = UniStr::from_str_lossy(file_name);
for (k, body) in w.catalog.iter_mut() {
if k.parent_id == catalog::ROOT_FOLDER_ID
&& k.name.code_units == target.code_units
&& body.len() >= 2
&& i16::from_be_bytes([body[0], body[1]]) == catalog::REC_FILE
{
return body;
}
}
panic!("no catalog file record for /{file_name}");
}
#[cfg(feature = "gzip")]
#[test]
fn read_decmpfs_type3_inline_zlib() {
use flate2::{Compression, write::ZlibEncoder};
use std::io::Write;
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut empty = std::io::Cursor::new(Vec::<u8>::new());
let cnid = hfs
.create_file(&mut dev, "/cmp.bin", &mut empty, 0, 0o644, 0, 0)
.unwrap();
let plain = b"hello hfsplus decmpfs inline zlib world!\n".repeat(20);
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(&plain).unwrap();
let compressed = enc.finish().unwrap();
let mut xattr = Vec::new();
xattr.extend_from_slice(&make_decmpfs_header(3, plain.len() as u64));
xattr.extend_from_slice(&compressed);
let block_size = hfs.volume_header.block_size;
let node_size = opts.node_size;
let attr_bytes_needed = (2 * node_size) as u64;
let blocks_needed = u32::try_from(attr_bytes_needed.div_ceil(u64::from(block_size)))
.expect("attribute fork block count fits in u32");
let w = hfs.writer.as_mut().unwrap();
let attr_start_block = w.allocate(blocks_needed).expect("allocate attributes file");
let mut attr_fork = ForkData {
logical_size: attr_bytes_needed,
clump_size: node_size,
total_blocks: blocks_needed,
extents: Default::default(),
};
attr_fork.extents[0] = volume_header::ExtentDescriptor {
start_block: attr_start_block,
block_count: blocks_needed,
};
w.attributes_file = attr_fork;
hfs.volume_header.attributes_file = attr_fork;
{
let body = find_root_file_body_mut(w, "cmp.bin");
body[41] |= decmpfs::UF_COMPRESSED;
}
hfs.flush(&mut dev).unwrap();
let attr_tree = synth_attributes_btree(node_size, cnid, &xattr);
let attr_off = u64::from(attr_start_block) * u64::from(block_size);
dev.write_at(attr_off, &attr_tree).unwrap();
let hfs = HfsPlus::open(&mut dev).unwrap();
let mut r = hfs.open_file_reader(&mut dev, "/cmp.bin").unwrap();
let mut got = Vec::new();
std::io::Read::read_to_end(&mut r, &mut got).unwrap();
assert_eq!(got.len(), plain.len(), "decompressed length matches header");
assert_eq!(got, plain, "decompressed bytes match original");
}
#[cfg(feature = "gzip")]
#[test]
fn read_decmpfs_type4_resource_fork_zlib() {
use flate2::{Compression, write::ZlibEncoder};
use std::io::Write;
let mut dev = crate::block::MemoryBackend::new(16 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut empty = std::io::Cursor::new(Vec::<u8>::new());
let cnid = hfs
.create_file(&mut dev, "/big.bin", &mut empty, 0, 0o644, 0, 0)
.unwrap();
let mut plain = Vec::new();
plain.extend(std::iter::repeat_n(0xABu8, decmpfs::HFSCOMPRESS_BLOCK_SIZE));
plain.extend_from_slice(b"hfscompression type-4 tail data");
let block1 = &plain[..decmpfs::HFSCOMPRESS_BLOCK_SIZE];
let block2 = &plain[decmpfs::HFSCOMPRESS_BLOCK_SIZE..];
let compress = |data: &[u8]| -> Vec<u8> {
let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
};
let c1 = compress(block1);
let c2 = compress(block2);
let block_count: u32 = 2;
let table_size = 4 + 8 * block_count as usize;
let blk1_off = table_size as u32;
let blk2_off = blk1_off + c1.len() as u32;
let mut rdata = Vec::new();
let inner_size: u32 = (table_size + c1.len() + c2.len()) as u32;
rdata.extend_from_slice(&inner_size.to_be_bytes());
rdata.extend_from_slice(&block_count.to_le_bytes());
rdata.extend_from_slice(&blk1_off.to_le_bytes());
rdata.extend_from_slice(&(c1.len() as u32).to_le_bytes());
rdata.extend_from_slice(&blk2_off.to_le_bytes());
rdata.extend_from_slice(&(c2.len() as u32).to_le_bytes());
rdata.extend_from_slice(&c1);
rdata.extend_from_slice(&c2);
let data_offset: u32 = 256;
let data_length: u32 = rdata.len() as u32;
let mut rf = Vec::new();
rf.extend_from_slice(&data_offset.to_be_bytes());
rf.extend_from_slice(&(data_offset + data_length).to_be_bytes());
rf.extend_from_slice(&data_length.to_be_bytes());
rf.extend_from_slice(&0u32.to_be_bytes());
rf.resize(data_offset as usize, 0);
rf.extend_from_slice(&rdata);
let xattr = make_decmpfs_header(4, plain.len() as u64).to_vec();
let block_size = hfs.volume_header.block_size;
let node_size = opts.node_size;
let attr_bytes_needed = (2 * node_size) as u64;
let attr_blocks = u32::try_from(attr_bytes_needed.div_ceil(u64::from(block_size))).unwrap();
let rf_blocks = u32::try_from((rf.len() as u64).div_ceil(u64::from(block_size))).unwrap();
let w = hfs.writer.as_mut().unwrap();
let attr_start = w.allocate(attr_blocks).unwrap();
let rf_start = w.allocate(rf_blocks).unwrap();
let mut attr_fork = ForkData {
logical_size: attr_bytes_needed,
clump_size: node_size,
total_blocks: attr_blocks,
extents: Default::default(),
};
attr_fork.extents[0] = volume_header::ExtentDescriptor {
start_block: attr_start,
block_count: attr_blocks,
};
w.attributes_file = attr_fork;
hfs.volume_header.attributes_file = attr_fork;
{
let body = find_root_file_body_mut(w, "big.bin");
body[41] |= decmpfs::UF_COMPRESSED;
body[168..176].copy_from_slice(&(rf.len() as u64).to_be_bytes());
body[176..180].copy_from_slice(&block_size.to_be_bytes());
body[180..184].copy_from_slice(&rf_blocks.to_be_bytes());
body[184..188].copy_from_slice(&rf_start.to_be_bytes());
body[188..192].copy_from_slice(&rf_blocks.to_be_bytes());
}
hfs.flush(&mut dev).unwrap();
let attr_tree = synth_attributes_btree(node_size, cnid, &xattr);
let attr_off = u64::from(attr_start) * u64::from(block_size);
dev.write_at(attr_off, &attr_tree).unwrap();
let rf_off = u64::from(rf_start) * u64::from(block_size);
dev.write_at(rf_off, &rf).unwrap();
let hfs = HfsPlus::open(&mut dev).unwrap();
let mut r = hfs.open_file_reader(&mut dev, "/big.bin").unwrap();
let mut got = Vec::new();
std::io::Read::read_to_end(&mut r, &mut got).unwrap();
assert_eq!(got.len(), plain.len());
assert_eq!(got, plain);
}
#[test]
fn read_decmpfs_unsupported_codec_errors_clearly() {
let mut dev = crate::block::MemoryBackend::new(8 * 1024 * 1024);
let opts = writer::FormatOpts::default();
let mut hfs = HfsPlus::format(&mut dev, &opts).unwrap();
let mut empty = std::io::Cursor::new(Vec::<u8>::new());
let cnid = hfs
.create_file(&mut dev, "/lzvn.bin", &mut empty, 0, 0o644, 0, 0)
.unwrap();
let xattr = make_decmpfs_header(7, 4).to_vec();
let block_size = hfs.volume_header.block_size;
let node_size = opts.node_size;
let attr_bytes_needed = (2 * node_size) as u64;
let attr_blocks = u32::try_from(attr_bytes_needed.div_ceil(u64::from(block_size))).unwrap();
let w = hfs.writer.as_mut().unwrap();
let attr_start = w.allocate(attr_blocks).unwrap();
let mut attr_fork = ForkData {
logical_size: attr_bytes_needed,
clump_size: node_size,
total_blocks: attr_blocks,
extents: Default::default(),
};
attr_fork.extents[0] = volume_header::ExtentDescriptor {
start_block: attr_start,
block_count: attr_blocks,
};
w.attributes_file = attr_fork;
hfs.volume_header.attributes_file = attr_fork;
{
let body = find_root_file_body_mut(w, "lzvn.bin");
body[41] |= decmpfs::UF_COMPRESSED;
}
hfs.flush(&mut dev).unwrap();
let attr_tree = synth_attributes_btree(node_size, cnid, &xattr);
let attr_off = u64::from(attr_start) * u64::from(block_size);
dev.write_at(attr_off, &attr_tree).unwrap();
let hfs = HfsPlus::open(&mut dev).unwrap();
let err = hfs.open_file_reader(&mut dev, "/lzvn.bin").err().unwrap();
assert!(
matches!(err, crate::Error::Unsupported(_)),
"LZVN should surface as Unsupported, got {err:?}"
);
}
}