#[cfg(feature = "rust173-polyfill")]
use disk_core::rust173::IntIsMultipleOf as _;
use std::io::{Read, Seek, SeekFrom, Write};
use byteorder::{BigEndian, ByteOrder};
use disk_core::entry::{EntryType, FileEntry};
use disk_core::filesystem::{
CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem, FilesystemError,
};
use disk_core::fsck::{FsckIssue, FsckResult, FsckStats, RepairReport};
pub const MFS_SIGNATURE: u16 = 0xD2D7;
const MDB_OFFSET: u64 = 1024;
pub const MFS_400K_BYTES: u64 = 409_600;
pub const MFS_800K_BYTES: u64 = 819_200;
const MAX_ALLOC_BLOCKS: u32 = 0xFFFF;
const MAX_ALLOC_CHAIN: usize = 4096;
#[derive(Debug, Clone)]
pub struct MfsMdb {
pub create_date: u32,
pub last_backup_date: u32,
pub volume_attributes: u16,
pub num_files: u16,
pub dir_start_sector: u16,
pub dir_length_sectors: u16,
pub num_alloc_blocks: u16,
pub alloc_block_size: u32,
pub clump_size: u32,
pub first_alloc_block_sector: u16,
pub next_file_number: u32,
pub free_blocks: u16,
pub volume_name: String,
map_offset: u64,
}
impl MfsMdb {
fn parse(buf: &[u8; 512]) -> Result<Self, FilesystemError> {
let sig = BigEndian::read_u16(&buf[0..2]);
if sig != MFS_SIGNATURE {
return Err(FilesystemError::InvalidData(format!(
"bad MFS signature: 0x{sig:04X} (expected 0x{MFS_SIGNATURE:04X})"
)));
}
let create_date = BigEndian::read_u32(&buf[2..6]);
let last_backup_date = BigEndian::read_u32(&buf[6..10]);
let volume_attributes = BigEndian::read_u16(&buf[10..12]);
let num_files = BigEndian::read_u16(&buf[12..14]);
let dir_start_sector = BigEndian::read_u16(&buf[14..16]);
let dir_length_sectors = BigEndian::read_u16(&buf[16..18]);
let num_alloc_blocks = BigEndian::read_u16(&buf[18..20]);
let alloc_block_size = BigEndian::read_u32(&buf[20..24]);
let clump_size = BigEndian::read_u32(&buf[24..28]);
let first_alloc_block_sector = BigEndian::read_u16(&buf[28..30]);
let next_file_number = BigEndian::read_u32(&buf[30..34]);
let free_blocks = BigEndian::read_u16(&buf[34..36]);
let name_len = buf[36] as usize;
if 37 + name_len > 64 {
return Err(FilesystemError::InvalidData(format!(
"MFS volume name length {name_len} exceeds 27"
)));
}
let volume_name = crate::hfs::mac_roman_to_utf8(&buf[37..37 + name_len]);
let map_offset = MDB_OFFSET + 64;
if alloc_block_size == 0 || alloc_block_size % 512 != 0 {
return Err(FilesystemError::InvalidData(format!(
"MFS allocation block size {alloc_block_size} is not a positive multiple of 512"
)));
}
if num_alloc_blocks as u32 > MAX_ALLOC_BLOCKS {
return Err(FilesystemError::InvalidData(format!(
"MFS allocation-block count {num_alloc_blocks} exceeds 65535"
)));
}
if dir_length_sectors == 0 {
return Err(FilesystemError::InvalidData(
"MFS file directory length is 0".into(),
));
}
Ok(MfsMdb {
create_date,
last_backup_date,
volume_attributes,
num_files,
dir_start_sector,
dir_length_sectors,
num_alloc_blocks,
alloc_block_size,
clump_size,
first_alloc_block_sector,
next_file_number,
free_blocks,
volume_name,
map_offset,
})
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] struct MfsDirEntry {
flags: u8,
finder_info: [u8; 16],
file_number: u32,
data_first_block: u16,
data_logical_length: u32,
rsrc_first_block: u16,
rsrc_logical_length: u32,
create_date: u32,
modify_date: u32,
name: String,
}
impl MfsDirEntry {
fn is_in_use(&self) -> bool {
self.flags & 0x80 != 0
}
fn finder_flags(&self) -> u16 {
u16::from_be_bytes([self.finder_info[8], self.finder_info[9]])
}
}
pub struct MfsFilesystem<R: Read + Seek + Send> {
reader: R,
partition_offset: u64,
pub(crate) mdb: MfsMdb,
entries: Vec<MfsDirEntry>,
map_bytes: Vec<u8>,
}
impl<R: Read + Seek + Send> MfsFilesystem<R> {
pub fn open(mut reader: R, partition_offset: u64) -> Result<Self, FilesystemError> {
reader.seek(SeekFrom::Start(partition_offset + MDB_OFFSET))?;
let mut mdb_buf = [0u8; 512];
reader.read_exact(&mut mdb_buf)?;
let mdb = MfsMdb::parse(&mdb_buf)?;
let map_bits = mdb.num_alloc_blocks as usize * 12;
let map_byte_len = map_bits.div_ceil(8);
let mut map_bytes = vec![0u8; map_byte_len];
reader.seek(SeekFrom::Start(partition_offset + mdb.map_offset))?;
reader.read_exact(&mut map_bytes)?;
let mut entries = Vec::new();
for sector_idx in 0..mdb.dir_length_sectors {
let sector_no = mdb.dir_start_sector as u64 + sector_idx as u64;
let sector_off = partition_offset + sector_no * 512;
reader.seek(SeekFrom::Start(sector_off))?;
let mut sec = [0u8; 512];
if reader.read_exact(&mut sec).is_err() {
break;
}
parse_dir_sector(&sec, &mut entries);
}
Ok(MfsFilesystem {
reader,
partition_offset,
mdb,
entries,
map_bytes,
})
}
fn entry_by_file_number(&self, fnum: u32) -> Option<&MfsDirEntry> {
self.entries
.iter()
.find(|e| e.is_in_use() && e.file_number == fnum)
}
fn map_get(&self, block: u16) -> u16 {
if block < 2 {
return 0;
}
let bit_off = (block as usize - 2) * 12;
let byte_off = bit_off / 8;
if byte_off + 1 >= self.map_bytes.len() {
return 0;
}
if block.is_multiple_of(2) {
let hi = self.map_bytes[byte_off] as u16;
let lo = (self.map_bytes[byte_off + 1] >> 4) as u16;
(hi << 4) | lo
} else {
let hi = (self.map_bytes[byte_off] & 0x0F) as u16;
let lo = self.map_bytes[byte_off + 1] as u16;
(hi << 8) | lo
}
}
fn alloc_block_offset(&self, block: u16) -> u64 {
debug_assert!(block >= 2);
let sectors_per_block = (self.mdb.alloc_block_size / 512) as u64;
let sector =
self.mdb.first_alloc_block_sector as u64 + (block as u64 - 2) * sectors_per_block;
sector * 512
}
fn read_chain(&mut self, first: u16, logical_len: u32) -> Result<Vec<u8>, FilesystemError> {
if first == 0 || logical_len == 0 {
return Ok(Vec::new());
}
let mut data = Vec::with_capacity(logical_len as usize);
let bs = self.mdb.alloc_block_size as usize;
let mut block = first;
for _hop in 0..MAX_ALLOC_CHAIN {
if block < 2 || block as u32 - 2 >= self.mdb.num_alloc_blocks as u32 {
return Err(FilesystemError::InvalidData(format!(
"MFS chain references out-of-range alloc block {block}"
)));
}
let off = self.partition_offset + self.alloc_block_offset(block);
self.reader.seek(SeekFrom::Start(off))?;
let want = (logical_len as usize).saturating_sub(data.len()).min(bs);
let mut chunk = vec![0u8; bs];
self.reader.read_exact(&mut chunk)?;
data.extend_from_slice(&chunk[..want]);
if data.len() >= logical_len as usize {
break;
}
let next = self.map_get(block);
if next == 1 {
break;
}
if next == 0 {
return Err(FilesystemError::InvalidData(format!(
"MFS chain hits a free block at {block}"
)));
}
block = next;
}
Ok(data)
}
pub fn read_resource_fork(&mut self, entry: &FileEntry) -> Result<Vec<u8>, FilesystemError> {
let fnum = entry.location as u32;
let de = self
.entry_by_file_number(fnum)
.ok_or_else(|| FilesystemError::NotFound(format!("file number {fnum} not found")))?
.clone();
self.read_chain(de.rsrc_first_block, de.rsrc_logical_length)
}
}
fn parse_dir_sector(sec: &[u8; 512], out: &mut Vec<MfsDirEntry>) {
let mut off = 0usize;
while off + 50 < sec.len() {
let flags = sec[off];
if flags & 0x80 == 0 {
break;
}
let mut finder_info = [0u8; 16];
finder_info.copy_from_slice(&sec[off + 2..off + 18]);
let file_number = BigEndian::read_u32(&sec[off + 18..off + 22]);
let data_first_block = BigEndian::read_u16(&sec[off + 22..off + 24]);
let data_logical_length = BigEndian::read_u32(&sec[off + 24..off + 28]);
let _data_phys_length = BigEndian::read_u32(&sec[off + 28..off + 32]);
let rsrc_first_block = BigEndian::read_u16(&sec[off + 32..off + 34]);
let rsrc_logical_length = BigEndian::read_u32(&sec[off + 34..off + 38]);
let _rsrc_phys_length = BigEndian::read_u32(&sec[off + 38..off + 42]);
let create_date = BigEndian::read_u32(&sec[off + 42..off + 46]);
let modify_date = BigEndian::read_u32(&sec[off + 46..off + 50]);
let name_len = sec[off + 50] as usize;
if off + 51 + name_len > sec.len() {
break;
}
let name = crate::hfs::mac_roman_to_utf8(&sec[off + 51..off + 51 + name_len]);
out.push(MfsDirEntry {
flags,
finder_info,
file_number,
data_first_block,
data_logical_length,
rsrc_first_block,
rsrc_logical_length,
create_date,
modify_date,
name,
});
let entry_len = 51 + name_len;
let padded = entry_len + (entry_len & 1);
off += padded;
}
}
pub const MFS_MAX_NAME_LEN: usize = 27;
fn mfs_names_equal(a: &str, b: &str) -> bool {
let a_bytes = crate::hfs::utf8_to_mac_roman(a).unwrap_or_else(|_| a.as_bytes().to_vec());
let b_bytes = crate::hfs::utf8_to_mac_roman(b).unwrap_or_else(|_| b.as_bytes().to_vec());
crate::hfs_common::compare_hfs_keys(0, &a_bytes, 0, &b_bytes) == std::cmp::Ordering::Equal
}
fn validate_mfs_name(name: &str) -> Result<Vec<u8>, FilesystemError> {
if name.is_empty() {
return Err(FilesystemError::InvalidData("MFS filename is empty".into()));
}
if name.contains(':') {
return Err(FilesystemError::InvalidData(
"MFS filename contains ':' (Mac path separator) — use '-' or '_'".into(),
));
}
let bytes = crate::hfs::utf8_to_mac_roman(name).map_err(|_| {
FilesystemError::InvalidData(
"MFS filename contains characters not representable in Mac Roman".into(),
)
})?;
if bytes.len() > MFS_MAX_NAME_LEN {
return Err(FilesystemError::InvalidData(format!(
"MFS filename is {} bytes; max is {MFS_MAX_NAME_LEN}",
bytes.len()
)));
}
Ok(bytes)
}
impl MfsDirEntry {
fn encode(&self, alloc_block_size: u32) -> Vec<u8> {
let physical = |logical: u32| -> u32 {
if logical == 0 || alloc_block_size == 0 {
0
} else {
logical
.div_ceil(alloc_block_size)
.saturating_mul(alloc_block_size)
}
};
let name_bytes = crate::hfs::utf8_to_mac_roman(&self.name).unwrap_or_default();
let name_len = name_bytes.len().min(MFS_MAX_NAME_LEN) as u8;
let total = 51 + name_len as usize;
let padded = total + (total & 1);
let mut buf = vec![0u8; padded];
buf[0] = self.flags;
buf[2..18].copy_from_slice(&self.finder_info);
BigEndian::write_u32(&mut buf[18..22], self.file_number);
BigEndian::write_u16(&mut buf[22..24], self.data_first_block);
BigEndian::write_u32(&mut buf[24..28], self.data_logical_length);
BigEndian::write_u32(&mut buf[28..32], physical(self.data_logical_length));
BigEndian::write_u16(&mut buf[32..34], self.rsrc_first_block);
BigEndian::write_u32(&mut buf[34..38], self.rsrc_logical_length);
BigEndian::write_u32(&mut buf[38..42], physical(self.rsrc_logical_length));
BigEndian::write_u32(&mut buf[42..46], self.create_date);
BigEndian::write_u32(&mut buf[46..50], self.modify_date);
buf[50] = name_len;
buf[51..51 + name_len as usize].copy_from_slice(&name_bytes[..name_len as usize]);
buf
}
}
impl<R: Read + Write + Seek + Send> MfsFilesystem<R> {
fn map_set(&mut self, block: u16, value: u16) {
debug_assert!(
block as u32 >= 2 && (block as u32) < self.mdb.num_alloc_blocks as u32 + 2,
"map_set out-of-range block {block}"
);
let value = value & 0x0FFF;
let bit_off = (block as usize - 2) * 12;
let byte_off = bit_off / 8;
if block.is_multiple_of(2) {
self.map_bytes[byte_off] = (value >> 4) as u8;
self.map_bytes[byte_off + 1] =
(self.map_bytes[byte_off + 1] & 0x0F) | (((value & 0x0F) as u8) << 4);
} else {
self.map_bytes[byte_off] =
(self.map_bytes[byte_off] & 0xF0) | ((value >> 8) as u8 & 0x0F);
self.map_bytes[byte_off + 1] = (value & 0xFF) as u8;
}
}
fn alloc_chain(&mut self, count: u32) -> Result<u16, FilesystemError> {
if count == 0 {
return Ok(0);
}
if (count as u16) > self.mdb.free_blocks {
return Err(FilesystemError::InvalidData(format!(
"out of space: need {count} alloc blocks, have {} free",
self.mdb.free_blocks
)));
}
let mut picked = Vec::with_capacity(count as usize);
for b in 2..(self.mdb.num_alloc_blocks as u32 + 2) {
if self.map_get(b as u16) == 0 {
picked.push(b as u16);
if picked.len() == count as usize {
break;
}
}
}
if picked.len() < count as usize {
return Err(FilesystemError::InvalidData(format!(
"fragmentation: {} free per MDB but only {} blocks found in map",
self.mdb.free_blocks,
picked.len()
)));
}
for w in picked.windows(2) {
let cur = w[0];
let nxt = w[1];
self.map_set(cur, nxt);
}
let last = *picked.last().expect("picked non-empty");
self.map_set(last, 1);
self.mdb.free_blocks -= count as u16;
Ok(picked[0])
}
fn free_chain(&mut self, first: u16) -> Result<(), FilesystemError> {
if first == 0 {
return Ok(());
}
let mut block = first;
let mut visited = std::collections::HashSet::new();
for _hop in 0..MAX_ALLOC_CHAIN {
if block < 2 || block as u32 - 2 >= self.mdb.num_alloc_blocks as u32 {
return Err(FilesystemError::InvalidData(format!(
"free_chain hit out-of-range block {block}"
)));
}
if !visited.insert(block) {
return Err(FilesystemError::InvalidData(format!(
"free_chain cycle at block {block}"
)));
}
let next = self.map_get(block);
self.map_set(block, 0);
self.mdb.free_blocks += 1;
if next == 1 || next == 0 {
return Ok(());
}
block = next;
}
Err(FilesystemError::InvalidData(
"free_chain ran past MAX_ALLOC_CHAIN".into(),
))
}
fn alloc_and_write_fork(&mut self, bytes: &[u8]) -> Result<(u16, u32), FilesystemError> {
let bs = self.mdb.alloc_block_size as usize;
let count = if bytes.is_empty() {
0
} else {
bytes.len().div_ceil(bs) as u32
};
let first = self.alloc_chain(count)?;
if first != 0 {
self.write_data_chain(first, bytes)?;
}
Ok((first, bytes.len() as u32))
}
fn write_data_chain(&mut self, first: u16, data: &[u8]) -> Result<(), FilesystemError> {
if first == 0 || data.is_empty() {
return Ok(());
}
let bs = self.mdb.alloc_block_size as usize;
let mut written = 0usize;
let mut block = first;
for _hop in 0..MAX_ALLOC_CHAIN {
let chunk_off = written;
let chunk_end = (written + bs).min(data.len());
let abs = self.partition_offset + self.alloc_block_offset(block);
self.reader.seek(SeekFrom::Start(abs))?;
let chunk = &data[chunk_off..chunk_end];
self.reader.write_all(chunk)?;
if chunk.len() < bs {
let pad = vec![0u8; bs - chunk.len()];
self.reader.write_all(&pad)?;
}
written = chunk_end;
if written >= data.len() {
return Ok(());
}
let next = self.map_get(block);
if next == 1 || next == 0 {
if written < data.len() {
return Err(FilesystemError::InvalidData(format!(
"write_data_chain short-chained: {} bytes unwritten",
data.len() - written
)));
}
return Ok(());
}
block = next;
}
Err(FilesystemError::InvalidData(
"write_data_chain ran past MAX_ALLOC_CHAIN".into(),
))
}
fn dir_write_back(&mut self) -> Result<(), FilesystemError> {
let sector_count = self.mdb.dir_length_sectors as usize;
let mut packed = vec![vec![0u8; 512]; sector_count];
let mut sector_idx = 0usize;
let mut byte_off = 0usize;
for e in self.entries.iter().filter(|e| e.is_in_use()) {
let bytes = e.encode(self.mdb.alloc_block_size);
if byte_off + bytes.len() + 1 > 512 {
sector_idx += 1;
byte_off = 0;
if sector_idx >= sector_count {
return Err(FilesystemError::InvalidData(format!(
"directory full: {} sectors of {sector_count} can't hold {} entries",
sector_count,
self.entries.iter().filter(|e| e.is_in_use()).count()
)));
}
}
packed[sector_idx][byte_off..byte_off + bytes.len()].copy_from_slice(&bytes);
byte_off += bytes.len();
}
for (i, sec) in packed.into_iter().enumerate() {
let sector_no = self.mdb.dir_start_sector as u64 + i as u64;
let off = self.partition_offset + sector_no * 512;
self.reader.seek(SeekFrom::Start(off))?;
self.reader.write_all(&sec)?;
}
Ok(())
}
fn mdb_write_back(&mut self) -> Result<(), FilesystemError> {
self.reader
.seek(SeekFrom::Start(self.partition_offset + MDB_OFFSET))?;
let mut buf = [0u8; 512];
self.reader.read_exact(&mut buf)?;
BigEndian::write_u16(&mut buf[0..2], MFS_SIGNATURE);
BigEndian::write_u32(&mut buf[2..6], self.mdb.create_date);
BigEndian::write_u32(&mut buf[6..10], self.mdb.last_backup_date);
BigEndian::write_u16(&mut buf[10..12], self.mdb.volume_attributes);
BigEndian::write_u16(&mut buf[12..14], self.mdb.num_files);
BigEndian::write_u16(&mut buf[14..16], self.mdb.dir_start_sector);
BigEndian::write_u16(&mut buf[16..18], self.mdb.dir_length_sectors);
BigEndian::write_u16(&mut buf[18..20], self.mdb.num_alloc_blocks);
BigEndian::write_u32(&mut buf[20..24], self.mdb.alloc_block_size);
BigEndian::write_u32(&mut buf[24..28], self.mdb.clump_size);
BigEndian::write_u16(&mut buf[28..30], self.mdb.first_alloc_block_sector);
BigEndian::write_u32(&mut buf[30..34], self.mdb.next_file_number);
BigEndian::write_u16(&mut buf[34..36], self.mdb.free_blocks);
let name_bytes = crate::hfs::utf8_to_mac_roman(&self.mdb.volume_name).unwrap_or_default();
let name_len = name_bytes.len().min(MFS_MAX_NAME_LEN);
buf[36] = name_len as u8;
for b in buf[37..37 + MFS_MAX_NAME_LEN].iter_mut() {
*b = 0;
}
buf[37..37 + name_len].copy_from_slice(&name_bytes[..name_len]);
self.reader
.seek(SeekFrom::Start(self.partition_offset + MDB_OFFSET))?;
self.reader.write_all(&buf)?;
Ok(())
}
fn map_write_back(&mut self) -> Result<(), FilesystemError> {
self.reader
.seek(SeekFrom::Start(self.partition_offset + self.mdb.map_offset))?;
let bytes = self.map_bytes.clone();
self.reader.write_all(&bytes)?;
Ok(())
}
}
struct MfsWalk {
used: Vec<bool>,
files: u32,
broken: Vec<String>,
}
impl<R: Read + Seek + Send> MfsFilesystem<R> {
fn last_block(&self) -> u16 {
self.mdb.num_alloc_blocks + 1
}
fn walk_fork(
&self,
first: u16,
owner: &str,
fork: &str,
used: &mut [bool],
broken: &mut Vec<String>,
) {
if first == 0 {
return;
}
let last = self.last_block();
let mut block = first;
for _ in 0..MAX_ALLOC_CHAIN {
if block < 2 || block > last {
broken.push(format!(
"file '{owner}' {fork} fork links to block {block}, outside the volume (2..={last})"
));
return;
}
if used[block as usize] {
broken.push(format!(
"block {block} is cross-linked (claimed by '{owner}' {fork} fork and another chain)"
));
return;
}
used[block as usize] = true;
let next = self.map_get(block);
if next == 1 {
return; }
if next == 0 {
broken.push(format!(
"file '{owner}' {fork} fork chain hits a free block after {block}"
));
return;
}
block = next;
}
broken.push(format!(
"file '{owner}' {fork} fork chain exceeds {MAX_ALLOC_CHAIN} blocks (runaway)"
));
}
fn compute_used(&self) -> MfsWalk {
let mut used = vec![false; self.last_block() as usize + 1];
let mut broken = Vec::new();
let mut files = 0u32;
let entries: Vec<MfsDirEntry> = self
.entries
.iter()
.filter(|e| e.is_in_use())
.cloned()
.collect();
for de in &entries {
files += 1;
self.walk_fork(
de.data_first_block,
&de.name,
"data",
&mut used,
&mut broken,
);
self.walk_fork(
de.rsrc_first_block,
&de.name,
"resource",
&mut used,
&mut broken,
);
}
MfsWalk {
used,
files,
broken,
}
}
fn map_free_count(&self) -> u32 {
(2..=self.last_block())
.filter(|&b| self.map_get(b) == 0)
.count() as u32
}
fn run_fsck(&mut self) -> Result<FsckResult, FilesystemError> {
let walk = self.compute_used();
let walk_clean = walk.broken.is_empty();
let last = self.last_block();
let mut leaked = 0u32;
for b in 2..=last {
if self.map_get(b) != 0 && !walk.used[b as usize] {
leaked += 1;
}
}
let mut errors = Vec::new();
for msg in &walk.broken {
errors.push(FsckIssue {
code: "MfsBrokenChain".into(),
message: msg.clone(),
repairable: false,
debug: false,
});
}
if leaked > 0 {
errors.push(FsckIssue {
code: "MfsLostBlocks".into(),
message: format!(
"{leaked} allocation block(s) marked used but unreachable from any file"
),
repairable: walk_clean,
debug: false,
});
}
let map_free = self.map_free_count();
if self.mdb.free_blocks as u32 != map_free {
errors.push(FsckIssue {
code: "MfsFreeCountMismatch".into(),
message: format!(
"MDB free-block count is {} but the allocation map has {map_free} free",
self.mdb.free_blocks
),
repairable: walk_clean,
debug: false,
});
}
if self.mdb.num_files as u32 != walk.files {
errors.push(FsckIssue {
code: "MfsFileCountMismatch".into(),
message: format!(
"MDB file count is {} but the directory holds {} file(s)",
self.mdb.num_files, walk.files
),
repairable: walk_clean,
debug: false,
});
}
let repairable = errors.iter().any(|e| e.repairable);
let free_after = map_free + leaked; Ok(FsckResult {
errors,
warnings: Vec::new(),
stats: FsckStats {
files_checked: walk.files,
directories_checked: 1,
extra: vec![
(
"free blocks".into(),
format!("{free_after} / {}", self.mdb.num_alloc_blocks),
),
(
"allocation map".into(),
if leaked == 0 && walk_clean {
"consistent".into()
} else {
"needs rebuild".into()
},
),
],
},
repairable,
orphaned_entries: Vec::new(),
})
}
}
impl<R: Read + Write + Seek + Send> MfsFilesystem<R> {
fn run_repair(&mut self) -> Result<RepairReport, FilesystemError> {
let walk = self.compute_used();
if !walk.broken.is_empty() {
return Ok(RepairReport {
fixes_applied: Vec::new(),
fixes_failed: vec![
"structural damage (broken/cross-linked chain) — the allocation map was left \
unchanged to avoid losing live blocks"
.into(),
],
unrepairable_count: walk.broken.len(),
});
}
let last = self.last_block();
let mut freed = 0u32;
for b in 2..=last {
if self.map_get(b) != 0 && !walk.used[b as usize] {
self.map_set(b, 0);
freed += 1;
}
}
let map_free = self.map_free_count();
let mut fixes = Vec::new();
if freed > 0 {
fixes.push(format!(
"reclaimed {freed} unreachable allocation block(s) into the free map"
));
}
if self.mdb.free_blocks as u32 != map_free {
fixes.push(format!("corrected the MDB free-block count to {map_free}"));
self.mdb.free_blocks = map_free as u16;
}
if self.mdb.num_files as u32 != walk.files {
fixes.push(format!("corrected the MDB file count to {}", walk.files));
self.mdb.num_files = walk.files as u16;
}
if fixes.is_empty() {
return Ok(RepairReport {
fixes_applied: Vec::new(),
fixes_failed: Vec::new(),
unrepairable_count: 0,
});
}
self.map_write_back()?;
self.mdb_write_back()?;
self.reader.flush()?;
Ok(RepairReport {
fixes_applied: fixes,
fixes_failed: Vec::new(),
unrepairable_count: 0,
})
}
}
impl<R: Read + Seek + Send> Filesystem for MfsFilesystem<R> {
fn root(&mut self) -> Result<FileEntry, FilesystemError> {
Ok(FileEntry::new_directory("/".into(), "/".into(), 0))
}
fn list_directory(&mut self, entry: &FileEntry) -> Result<Vec<FileEntry>, FilesystemError> {
if entry.path != "/" {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(self.entries.len());
for de in &self.entries {
if !de.is_in_use() {
continue;
}
let path = format!("/{}", de.name);
let mut fe = FileEntry::new_file(
de.name.clone(),
path,
de.data_logical_length as u64,
de.file_number as u64,
);
fe.type_code = Some(de.finder_info[0..4].try_into().unwrap());
fe.creator_code = Some(de.finder_info[4..8].try_into().unwrap());
fe.finder_flags = Some(de.finder_flags());
if de.rsrc_logical_length > 0 {
fe.resource_fork_size = Some(de.rsrc_logical_length as u64);
}
fe.modified_unix = disk_core::times::mac_local_to_unix(de.modify_date);
if de.modify_date != 0 || de.create_date != 0 {
fe.mac_dates = Some((de.create_date, de.modify_date, 0));
if let Some(s) = crate::hfs_common::format_mac_date(de.modify_date) {
fe.modified = Some(s);
}
}
out.push(fe);
}
out.sort_by_key(|a| a.name.to_lowercase());
Ok(out)
}
fn read_file(
&mut self,
entry: &FileEntry,
max_bytes: usize,
) -> Result<Vec<u8>, FilesystemError> {
let fnum = entry.location as u32;
let de = self
.entry_by_file_number(fnum)
.ok_or_else(|| FilesystemError::NotFound(format!("file number {fnum} not found")))?
.clone();
let mut data = self.read_chain(de.data_first_block, de.data_logical_length)?;
data.truncate(max_bytes);
Ok(data)
}
fn validate_name(&self, name: &str) -> Result<(), FilesystemError> {
validate_mfs_name(name).map(|_| ())
}
fn fs_type(&self) -> &str {
"MFS"
}
fn volume_label(&self) -> Option<&str> {
if self.mdb.volume_name.is_empty() {
None
} else {
Some(&self.mdb.volume_name)
}
}
fn total_size(&self) -> u64 {
self.mdb.num_alloc_blocks as u64 * self.mdb.alloc_block_size as u64
}
fn used_size(&self) -> u64 {
(self.mdb.num_alloc_blocks - self.mdb.free_blocks) as u64 * self.mdb.alloc_block_size as u64
}
fn allocation_unit(&self) -> Option<u64> {
if self.mdb.alloc_block_size == 0 {
None
} else {
Some(self.mdb.alloc_block_size as u64)
}
}
fn write_resource_fork_to(
&mut self,
entry: &FileEntry,
writer: &mut dyn std::io::Write,
) -> Result<u64, FilesystemError> {
let data = self.read_resource_fork(entry)?;
writer.write_all(&data)?;
Ok(data.len() as u64)
}
fn resource_fork_size(&mut self, entry: &FileEntry) -> u64 {
self.entry_by_file_number(entry.location as u32)
.map(|de| de.rsrc_logical_length as u64)
.unwrap_or(0)
}
fn fsck(&mut self) -> Option<Result<FsckResult, FilesystemError>> {
Some(self.run_fsck())
}
}
impl<R: Read + Write + Seek + Send> EditableFilesystem for MfsFilesystem<R> {
fn as_filesystem(&self) -> &dyn disk_core::filesystem::Filesystem {
self
}
fn as_filesystem_mut(&mut self) -> &mut dyn disk_core::filesystem::Filesystem {
self
}
fn create_file(
&mut self,
parent: &FileEntry,
name: &str,
data: &mut dyn std::io::Read,
data_len: u64,
options: &CreateFileOptions,
) -> Result<FileEntry, FilesystemError> {
if parent.path != "/" {
return Err(FilesystemError::Unsupported(
"MFS is flat; only the root directory accepts new files".into(),
));
}
let _ = validate_mfs_name(name)?;
if self
.entries
.iter()
.any(|e| e.is_in_use() && mfs_names_equal(&e.name, name))
{
return Err(FilesystemError::InvalidData(format!(
"MFS file '{name}' already exists"
)));
}
if data_len > u32::MAX as u64 {
return Err(FilesystemError::InvalidData(
"MFS file size capped at u32::MAX bytes".into(),
));
}
let mut bytes = Vec::with_capacity(data_len as usize);
data.read_to_end(&mut bytes)?;
if bytes.len() as u64 != data_len {
return Err(FilesystemError::InvalidData(format!(
"data_len {data_len} != actual {} bytes",
bytes.len()
)));
}
let (first_block, _) = self.alloc_and_write_fork(&bytes)?;
let rsrc_bytes: Vec<u8> = match &options.resource_fork {
Some(disk_core::filesystem::ResourceForkSource::Data(d)) => d.clone(),
Some(disk_core::filesystem::ResourceForkSource::File(p)) => {
std::fs::read(p).map_err(FilesystemError::Io)?
}
None => Vec::new(),
};
let (rsrc_first_block, rsrc_logical_length) = match self.alloc_and_write_fork(&rsrc_bytes) {
Ok(v) => v,
Err(e) => {
let _ = self.free_chain(first_block);
return Err(e);
}
};
let file_number = self.mdb.next_file_number;
self.mdb.next_file_number += 1;
self.mdb.num_files += 1;
let (type_code, creator_code) = crate::hfs_common::resolve_create_type_creator(
name,
options.os_type,
options.os_creator,
options.type_code.as_deref(),
options.creator_code.as_deref(),
);
let mut finder_info = [0u8; 16];
finder_info[0..4].copy_from_slice(&type_code);
finder_info[4..8].copy_from_slice(&creator_code);
let mtime_secs = options
.unix_times
.map(|t| t.mtime_or_now())
.unwrap_or_else(disk_core::times::now);
let stamp = disk_core::times::unix_to_mac_local(mtime_secs);
let entry = MfsDirEntry {
flags: 0x80, finder_info,
file_number,
data_first_block: first_block,
data_logical_length: bytes.len() as u32,
rsrc_first_block,
rsrc_logical_length,
create_date: stamp,
modify_date: stamp,
name: name.to_string(),
};
self.entries.push(entry);
let mut fe = FileEntry::new_file(
name.to_string(),
format!("/{name}"),
bytes.len() as u64,
file_number as u64,
);
fe.type_code = Some(type_code);
fe.creator_code = Some(creator_code);
if rsrc_logical_length > 0 {
fe.resource_fork_size = Some(rsrc_logical_length as u64);
}
fe.modified_unix = Some(mtime_secs);
fe.mac_dates = Some((stamp, stamp, 0));
Ok(fe)
}
fn create_directory(
&mut self,
_parent: &FileEntry,
_name: &str,
_options: &CreateDirectoryOptions,
) -> Result<FileEntry, FilesystemError> {
Err(FilesystemError::Unsupported(
"MFS has no directory hierarchy — all files live in the volume root".into(),
))
}
fn delete_entry(
&mut self,
parent: &FileEntry,
entry: &FileEntry,
) -> Result<(), FilesystemError> {
if parent.path != "/" {
return Err(FilesystemError::Unsupported(
"MFS is flat; only files in the root are deletable".into(),
));
}
if entry.entry_type != EntryType::File {
return Err(FilesystemError::InvalidData(
"MFS only stores files; cannot delete a directory".into(),
));
}
let fnum = entry.location as u32;
let (data_head, rsrc_head) = {
let de = self
.entries
.iter()
.find(|e| e.is_in_use() && e.file_number == fnum)
.ok_or_else(|| {
FilesystemError::NotFound(format!("file number {fnum} not in directory"))
})?;
(de.data_first_block, de.rsrc_first_block)
};
self.free_chain(data_head)?;
self.free_chain(rsrc_head)?;
self.entries
.retain(|e| !(e.is_in_use() && e.file_number == fnum));
self.mdb.num_files = self.mdb.num_files.saturating_sub(1);
Ok(())
}
fn sync_metadata(&mut self) -> Result<(), FilesystemError> {
self.dir_write_back()?;
self.map_write_back()?;
self.mdb_write_back()?;
self.reader.flush()?;
Ok(())
}
fn free_space(&mut self) -> Result<u64, FilesystemError> {
Ok(self.mdb.free_blocks as u64 * self.mdb.alloc_block_size as u64)
}
fn repair(&mut self) -> Result<RepairReport, FilesystemError> {
self.run_repair()
}
fn write_resource_fork(
&mut self,
entry: &FileEntry,
data: &mut dyn std::io::Read,
len: u64,
) -> Result<(), FilesystemError> {
if len > u32::MAX as u64 {
return Err(FilesystemError::InvalidData(
"MFS resource fork capped at u32::MAX bytes".into(),
));
}
let fnum = entry.location as u32;
let old_head = self
.entries
.iter()
.find(|e| e.is_in_use() && e.file_number == fnum)
.map(|e| e.rsrc_first_block)
.ok_or_else(|| {
FilesystemError::NotFound(format!("file number {fnum} not in directory"))
})?;
let mut bytes = Vec::with_capacity(len as usize);
data.read_to_end(&mut bytes)?;
self.free_chain(old_head)?;
let (first, rlen) = self.alloc_and_write_fork(&bytes)?;
let de = self
.entries
.iter_mut()
.find(|e| e.is_in_use() && e.file_number == fnum)
.expect("entry present after lookup above");
de.rsrc_first_block = first;
de.rsrc_logical_length = rlen;
Ok(())
}
fn set_type_creator(
&mut self,
entry: &FileEntry,
type_code: &str,
creator_code: &str,
) -> Result<(), FilesystemError> {
if type_code.len() != 4 || creator_code.len() != 4 {
return Err(FilesystemError::InvalidData(
"Mac OSType / creator must be exactly 4 chars".into(),
));
}
let fnum = entry.location as u32;
let de = self
.entries
.iter_mut()
.find(|e| e.is_in_use() && e.file_number == fnum)
.ok_or_else(|| {
FilesystemError::NotFound(format!("file number {fnum} not in directory"))
})?;
de.finder_info[0..4].copy_from_slice(type_code.as_bytes());
de.finder_info[4..8].copy_from_slice(creator_code.as_bytes());
Ok(())
}
fn rename(
&mut self,
parent: &FileEntry,
entry: &FileEntry,
new_name: &str,
) -> Result<(), FilesystemError> {
if new_name == entry.name {
return Ok(());
}
if parent.path != "/" {
return Err(FilesystemError::Unsupported(
"MFS is flat; only files in the root are renamable".into(),
));
}
if entry.entry_type != EntryType::File {
return Err(FilesystemError::InvalidData(
"MFS only stores files; cannot rename a directory".into(),
));
}
let _ = validate_mfs_name(new_name)?;
let fnum = entry.location as u32;
if self
.entries
.iter()
.any(|e| e.is_in_use() && e.file_number != fnum && mfs_names_equal(&e.name, new_name))
{
return Err(FilesystemError::AlreadyExists(new_name.to_string()));
}
let de = self
.entries
.iter_mut()
.find(|e| e.is_in_use() && e.file_number == fnum)
.ok_or_else(|| {
FilesystemError::NotFound(format!("file number {fnum} not in directory"))
})?;
de.name = new_name.to_string();
Ok(())
}
}
struct MfsBlankGeom {
total_sectors: u64,
alloc_block_size: u32,
num_alloc_blocks: u16,
dir_start_sector: u16,
dir_length_sectors: u16,
first_alloc_block_sector: u16,
}
fn blank_dir_sectors(num_alloc_blocks: u32) -> u64 {
(num_alloc_blocks as u64 / 40).clamp(12, 255)
}
fn mfs_blank_geometry(size_bytes: u64) -> Result<MfsBlankGeom, FilesystemError> {
const SECTOR: u64 = 512;
if size_bytes < 32 * SECTOR {
return Err(FilesystemError::InvalidData(
"MFS volume must be at least 16 KiB".into(),
));
}
let total_sectors = size_bytes / SECTOR;
for spb in 2..=128u64 {
let abs = spb * SECTOR;
let mut first_ab: u64 = 16; let mut num_ab = 0u64;
let mut dir_start = 4u64;
let mut dir_len = 12u64;
let mut converged = false;
for _ in 0..16 {
if total_sectors <= first_ab {
break;
}
num_ab = (total_sectors - first_ab) / spb;
let map_bytes = (num_ab * 12).div_ceil(8);
let mdb_map_sectors = (64 + map_bytes).div_ceil(SECTOR);
dir_start = 2 + mdb_map_sectors;
dir_len = blank_dir_sectors(num_ab as u32);
let next_first = dir_start + dir_len;
if next_first == first_ab {
converged = true;
break;
}
first_ab = next_first;
}
if !converged || num_ab == 0 {
continue;
}
if num_ab > 4094 {
continue; }
return Ok(MfsBlankGeom {
total_sectors,
alloc_block_size: abs as u32,
num_alloc_blocks: num_ab as u16,
dir_start_sector: dir_start as u16,
dir_length_sectors: dir_len as u16,
first_alloc_block_sector: first_ab as u16,
});
}
Err(FilesystemError::InvalidData(
"MFS volume too large to format (max ~256 MB)".into(),
))
}
pub fn create_blank_mfs(size_bytes: u64, name: &str) -> Result<Vec<u8>, FilesystemError> {
let g = mfs_blank_geometry(size_bytes)?;
let total_bytes = g.total_sectors * 512;
let mut disk = vec![0u8; total_bytes as usize];
let data_bytes = g.num_alloc_blocks as u32 * g.alloc_block_size;
let clump = (g.alloc_block_size.saturating_mul(8)).min(data_bytes.max(g.alloc_block_size));
let mdb = &mut disk[MDB_OFFSET as usize..MDB_OFFSET as usize + 512];
BigEndian::write_u16(&mut mdb[0..2], MFS_SIGNATURE);
BigEndian::write_u16(&mut mdb[10..12], 0); BigEndian::write_u16(&mut mdb[12..14], 0); BigEndian::write_u16(&mut mdb[14..16], g.dir_start_sector);
BigEndian::write_u16(&mut mdb[16..18], g.dir_length_sectors);
BigEndian::write_u16(&mut mdb[18..20], g.num_alloc_blocks);
BigEndian::write_u32(&mut mdb[20..24], g.alloc_block_size);
BigEndian::write_u32(&mut mdb[24..28], clump);
BigEndian::write_u16(&mut mdb[28..30], g.first_alloc_block_sector);
BigEndian::write_u32(&mut mdb[30..34], 1); BigEndian::write_u16(&mut mdb[34..36], g.num_alloc_blocks);
let name_bytes = crate::hfs::utf8_to_mac_roman(name).unwrap_or_default();
let name_len = name_bytes.len().min(MFS_MAX_NAME_LEN);
mdb[36] = name_len as u8;
mdb[37..37 + name_len].copy_from_slice(&name_bytes[..name_len]);
Ok(disk)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn build_test_mfs() -> Vec<u8> {
const SECTOR: usize = 512;
const NUM_SECTORS: usize = 32; const ALLOC_BLOCK_SIZE: u32 = 1024;
const FIRST_ALLOC_BLOCK_SECTOR: u16 = 5;
const DIR_START: u16 = 3;
const DIR_LEN: u16 = 2;
const NUM_ALLOC_BLOCKS: u16 = 8;
let mut disk = vec![0u8; NUM_SECTORS * SECTOR];
let mdb = &mut disk[1024..1024 + 512];
BigEndian::write_u16(&mut mdb[0..2], MFS_SIGNATURE);
BigEndian::write_u32(&mut mdb[2..6], 0); BigEndian::write_u32(&mut mdb[6..10], 0); BigEndian::write_u16(&mut mdb[10..12], 0); BigEndian::write_u16(&mut mdb[12..14], 2); BigEndian::write_u16(&mut mdb[14..16], DIR_START);
BigEndian::write_u16(&mut mdb[16..18], DIR_LEN);
BigEndian::write_u16(&mut mdb[18..20], NUM_ALLOC_BLOCKS);
BigEndian::write_u32(&mut mdb[20..24], ALLOC_BLOCK_SIZE);
BigEndian::write_u32(&mut mdb[24..28], ALLOC_BLOCK_SIZE);
BigEndian::write_u16(&mut mdb[28..30], FIRST_ALLOC_BLOCK_SECTOR);
BigEndian::write_u32(&mut mdb[30..34], 3); BigEndian::write_u16(&mut mdb[34..36], NUM_ALLOC_BLOCKS - 3); let vname = b"Tester";
mdb[36] = vname.len() as u8;
mdb[37..37 + vname.len()].copy_from_slice(vname);
let map_start = 1024 + 64;
let mut map = vec![0u8; 12];
write_map_entry(&mut map, 2, 1);
write_map_entry(&mut map, 3, 1);
write_map_entry(&mut map, 4, 1);
disk[map_start..map_start + map.len()].copy_from_slice(&map);
let mut dir_sec = vec![0u8; SECTOR];
let off = encode_dir_entry(
&mut dir_sec,
0,
DirEntryIn {
flags: 0x80,
file_number: 1,
data_first_block: 2,
data_logical_length: 9, data_physical_length: ALLOC_BLOCK_SIZE,
rsrc_first_block: 0,
rsrc_logical_length: 0,
rsrc_physical_length: 0,
type_code: *b"TEXT",
creator_code: *b"ttxt",
name: b"Hello",
},
);
let off = encode_dir_entry(
&mut dir_sec,
off,
DirEntryIn {
flags: 0x80,
file_number: 2,
data_first_block: 3,
data_logical_length: 11, data_physical_length: ALLOC_BLOCK_SIZE,
rsrc_first_block: 4,
rsrc_logical_length: 4, rsrc_physical_length: ALLOC_BLOCK_SIZE,
type_code: *b"TEXT",
creator_code: *b"ttxt",
name: b"Doc",
},
);
let _ = off;
disk[3 * SECTOR..3 * SECTOR + SECTOR].copy_from_slice(&dir_sec);
let alloc_start = FIRST_ALLOC_BLOCK_SECTOR as usize * SECTOR;
disk[alloc_start..alloc_start + 9].copy_from_slice(b"hello mfs");
let b3 = alloc_start + ALLOC_BLOCK_SIZE as usize;
disk[b3..b3 + 11].copy_from_slice(b"first line\n");
let b4 = alloc_start + 2 * ALLOC_BLOCK_SIZE as usize;
disk[b4..b4 + 4].copy_from_slice(b"RSRC");
disk
}
struct DirEntryIn<'a> {
flags: u8,
file_number: u32,
data_first_block: u16,
data_logical_length: u32,
data_physical_length: u32,
rsrc_first_block: u16,
rsrc_logical_length: u32,
rsrc_physical_length: u32,
type_code: [u8; 4],
creator_code: [u8; 4],
name: &'a [u8],
}
fn encode_dir_entry(buf: &mut [u8], off: usize, e: DirEntryIn) -> usize {
buf[off] = e.flags;
buf[off + 1] = 0; buf[off + 2..off + 6].copy_from_slice(&e.type_code);
buf[off + 6..off + 10].copy_from_slice(&e.creator_code);
BigEndian::write_u32(&mut buf[off + 18..off + 22], e.file_number);
BigEndian::write_u16(&mut buf[off + 22..off + 24], e.data_first_block);
BigEndian::write_u32(&mut buf[off + 24..off + 28], e.data_logical_length);
BigEndian::write_u32(&mut buf[off + 28..off + 32], e.data_physical_length);
BigEndian::write_u16(&mut buf[off + 32..off + 34], e.rsrc_first_block);
BigEndian::write_u32(&mut buf[off + 34..off + 38], e.rsrc_logical_length);
BigEndian::write_u32(&mut buf[off + 38..off + 42], e.rsrc_physical_length);
buf[off + 50] = e.name.len() as u8;
buf[off + 51..off + 51 + e.name.len()].copy_from_slice(e.name);
let entry_len = 51 + e.name.len();
off + entry_len + (entry_len & 1)
}
fn write_map_entry(map: &mut [u8], block: u16, value: u16) {
let value = value & 0x0FFF;
let bit_off = (block as usize - 2) * 12;
let byte_off = bit_off / 8;
if block.is_multiple_of(2) {
map[byte_off] = (value >> 4) as u8;
map[byte_off + 1] = (map[byte_off + 1] & 0x0F) | (((value & 0x0F) as u8) << 4);
} else {
map[byte_off] = (map[byte_off] & 0xF0) | ((value >> 8) as u8 & 0x0F);
map[byte_off + 1] = (value & 0xFF) as u8;
}
}
#[test]
fn parses_mdb_from_synthetic_volume() {
let disk = build_test_mfs();
let cur = Cursor::new(disk);
let fs = MfsFilesystem::open(cur, 0).unwrap();
assert_eq!(fs.mdb.num_files, 2);
assert_eq!(fs.mdb.alloc_block_size, 1024);
assert_eq!(fs.mdb.num_alloc_blocks, 8);
assert_eq!(fs.mdb.first_alloc_block_sector, 5);
assert_eq!(fs.volume_label(), Some("Tester"));
assert_eq!(fs.fs_type(), "MFS");
}
#[test]
fn lists_root_directory() {
let disk = build_test_mfs();
let cur = Cursor::new(disk);
let mut fs = MfsFilesystem::open(cur, 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
assert_eq!(entries.len(), 2);
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"Doc"));
assert!(names.contains(&"Hello"));
let doc = entries.iter().find(|e| e.name == "Doc").unwrap();
assert_eq!(doc.resource_fork_size, Some(4));
let hello = entries.iter().find(|e| e.name == "Hello").unwrap();
assert_eq!(hello.resource_fork_size, None);
assert_eq!(hello.type_code, Some(*b"TEXT"));
assert_eq!(hello.creator_code, Some(*b"ttxt"));
}
#[test]
fn reads_data_fork() {
let disk = build_test_mfs();
let cur = Cursor::new(disk);
let mut fs = MfsFilesystem::open(cur, 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
let hello = entries.iter().find(|e| e.name == "Hello").unwrap();
let data = fs.read_file(hello, 1024).unwrap();
assert_eq!(&data, b"hello mfs");
let doc = entries.iter().find(|e| e.name == "Doc").unwrap();
let data = fs.read_file(doc, 1024).unwrap();
assert_eq!(&data, b"first line\n");
}
#[test]
fn reads_resource_fork() {
let disk = build_test_mfs();
let cur = Cursor::new(disk);
let mut fs = MfsFilesystem::open(cur, 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
let doc = entries.iter().find(|e| e.name == "Doc").unwrap();
let rsrc = fs.read_resource_fork(doc).unwrap();
assert_eq!(&rsrc, b"RSRC");
let hello = entries.iter().find(|e| e.name == "Hello").unwrap();
let empty = fs.read_resource_fork(hello).unwrap();
assert!(empty.is_empty());
}
#[test]
fn resource_fork_via_trait_object() {
let disk = build_test_mfs();
let mut fs = MfsFilesystem::open(Cursor::new(disk), 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
let hello = entries.iter().find(|e| e.name == "Hello").unwrap().clone();
let fsys: &mut dyn Filesystem = &mut fs;
assert_eq!(fsys.resource_fork_size(&doc), 4);
let mut buf = Vec::new();
assert_eq!(fsys.write_resource_fork_to(&doc, &mut buf).unwrap(), 4);
assert_eq!(&buf, b"RSRC");
assert_eq!(fsys.resource_fork_size(&hello), 0);
let mut empty = Vec::new();
assert_eq!(fsys.write_resource_fork_to(&hello, &mut empty).unwrap(), 0);
assert!(empty.is_empty());
}
#[test]
fn resource_fork_survives_real_export_consumer() {
use crate::fork_export::export_file_with_fork;
use crate::resource_fork::ResourceForkMode;
let dir = std::env::temp_dir().join(format!("rb_mfs_fork_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut fs = MfsFilesystem::open(Cursor::new(build_test_mfs()), 0).unwrap();
let root = fs.root().unwrap();
let doc = fs
.list_directory(&root)
.unwrap()
.into_iter()
.find(|e| e.name == "Doc")
.unwrap();
export_file_with_fork(&mut fs, &doc, &dir, "Doc", ResourceForkMode::SeparateRsrc).unwrap();
assert_eq!(std::fs::read(dir.join("Doc")).unwrap(), b"first line\n");
assert_eq!(std::fs::read(dir.join("Doc.rsrc")).unwrap(), b"RSRC");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_file_with_resource_fork_persists() {
use disk_core::filesystem::ResourceForkSource;
let mut cur = Cursor::new(build_test_mfs());
{
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
let opts = CreateFileOptions {
resource_fork: Some(ResourceForkSource::Data(b"NEW-RSRC-BYTES".to_vec())),
..Default::default()
};
let data = b"data fork content";
let fe = fs
.create_file(&root, "Forked", &mut &data[..], data.len() as u64, &opts)
.unwrap();
assert_eq!(fe.resource_fork_size, Some(14));
fs.sync_metadata().unwrap();
}
cur.set_position(0);
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
let forked = fs
.list_directory(&root)
.unwrap()
.into_iter()
.find(|e| e.name == "Forked")
.unwrap();
assert_eq!(forked.resource_fork_size, Some(14));
assert_eq!(
fs.read_file(&forked, usize::MAX).unwrap(),
b"data fork content"
);
let fsys: &mut dyn Filesystem = &mut fs;
let mut buf = Vec::new();
fsys.write_resource_fork_to(&forked, &mut buf).unwrap();
assert_eq!(&buf, b"NEW-RSRC-BYTES");
}
#[test]
fn write_resource_fork_sets_and_replaces() {
let mut cur = Cursor::new(build_test_mfs());
{
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
let fe = fs
.create_file(
&root,
"SetR",
&mut &b"body"[..],
4,
&CreateFileOptions::default(),
)
.unwrap();
fs.write_resource_fork(&fe, &mut &b"FIRST"[..], 5).unwrap();
fs.sync_metadata().unwrap();
}
cur.set_position(0);
{
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
let e = fs
.list_directory(&root)
.unwrap()
.into_iter()
.find(|e| e.name == "SetR")
.unwrap();
assert_eq!(fs.read_resource_fork(&e).unwrap(), b"FIRST");
fs.write_resource_fork(&e, &mut &b"SECOND-and-longer"[..], 17)
.unwrap();
fs.sync_metadata().unwrap();
}
cur.set_position(0);
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
let e = fs
.list_directory(&root)
.unwrap()
.into_iter()
.find(|e| e.name == "SetR")
.unwrap();
assert_eq!(fs.read_resource_fork(&e).unwrap(), b"SECOND-and-longer");
}
#[test]
fn rejects_non_mfs_signature() {
let mut disk = build_test_mfs();
disk[1024] = 0xFF;
disk[1025] = 0xFF;
let cur = Cursor::new(disk);
match MfsFilesystem::open(cur, 0) {
Ok(_) => panic!("expected open to fail on bad signature"),
Err(FilesystemError::InvalidData(msg)) => {
assert!(msg.contains("bad MFS signature"), "got: {msg}");
}
Err(other) => panic!("expected InvalidData, got {other:?}"),
}
}
#[test]
fn create_blank_400k_round_trips_and_fscks_clean() {
let img = create_blank_mfs(MFS_400K_BYTES, "Blank400").unwrap();
assert_eq!(img.len() as u64, MFS_400K_BYTES);
let mut fs = MfsFilesystem::open(Cursor::new(img), 0).unwrap();
assert_eq!(fs.fs_type(), "MFS");
assert_eq!(fs.volume_label(), Some("Blank400"));
assert_eq!(fs.mdb.num_files, 0);
assert_eq!(fs.mdb.free_blocks, fs.mdb.num_alloc_blocks);
let root = fs.root().unwrap();
assert!(fs.list_directory(&root).unwrap().is_empty());
let res = fs.fsck().unwrap().unwrap();
assert!(
res.is_clean(),
"blank 400K should fsck clean: {:?}",
res.errors
);
}
#[test]
fn create_blank_geometry_valid_across_sizes() {
for size in [
MFS_400K_BYTES,
MFS_800K_BYTES,
1_474_560, 4 * 1024 * 1024,
20 * 1024 * 1024,
] {
let img = create_blank_mfs(size, "Vol").unwrap();
let mut fs = MfsFilesystem::open(Cursor::new(img), 0).unwrap();
let (first_ab, nab, abs) = (
fs.mdb.first_alloc_block_sector as u64,
fs.mdb.num_alloc_blocks as u64,
fs.mdb.alloc_block_size as u64,
);
let last_sector = first_ab + nab * (abs / 512);
assert!(
last_sector <= size / 512,
"size {size}: data region ends at sector {last_sector}, past {}",
size / 512
);
assert!(nab <= 4094, "size {size}: too many blocks");
let res = fs.fsck().unwrap().unwrap();
assert!(
res.is_clean(),
"size {size} should fsck clean: {:?}",
res.errors
);
}
}
#[test]
fn create_blank_too_small_is_rejected() {
assert!(create_blank_mfs(8 * 1024, "X").is_err());
}
#[test]
fn blank_volume_accepts_files_and_stays_clean() {
let img = create_blank_mfs(MFS_400K_BYTES, "Work").unwrap();
let mut cur = Cursor::new(img);
{
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
fs.create_file(
&root,
"ReadMe",
&mut &b"hello from a blank MFS volume"[..],
29,
&CreateFileOptions::default(),
)
.unwrap();
fs.sync_metadata().unwrap();
}
cur.set_position(0);
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "ReadMe");
assert_eq!(
fs.read_file(&entries[0], usize::MAX).unwrap(),
b"hello from a blank MFS volume"
);
let res = fs.fsck().unwrap().unwrap();
assert!(
res.is_clean(),
"populated volume should fsck clean: {:?}",
res.errors
);
}
#[test]
fn fsck_clean_on_synthetic_volume() {
let mut fs = MfsFilesystem::open(Cursor::new(build_test_mfs()), 0).unwrap();
let res = fs.fsck().unwrap().unwrap();
assert!(res.is_clean(), "expected clean, got {:?}", res.errors);
assert_eq!(res.stats.files_checked, 2);
}
#[test]
fn fsck_detects_and_repairs_leaked_block() {
let mut cur = Cursor::new(build_test_mfs());
{
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
fs.map_set(6, 1);
let res = fs.fsck().unwrap().unwrap();
assert!(!res.is_clean());
assert!(res
.errors
.iter()
.any(|e| e.code == "MfsLostBlocks" && e.repairable));
assert!(res.repairable);
let report = fs.repair().unwrap();
assert_eq!(report.unrepairable_count, 0);
assert!(!report.fixes_applied.is_empty());
fs.reader.flush().unwrap();
}
cur.set_position(0);
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
assert_eq!(fs.map_get(6), 0, "block 6 reclaimed");
let res = fs.fsck().unwrap().unwrap();
assert!(
res.is_clean(),
"post-repair should be clean: {:?}",
res.errors
);
}
#[test]
fn fsck_detects_and_repairs_file_count() {
let mut cur = Cursor::new(build_test_mfs());
{
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
fs.mdb.num_files = 9; let res = fs.fsck().unwrap().unwrap();
assert!(res
.errors
.iter()
.any(|e| e.code == "MfsFileCountMismatch" && e.repairable));
fs.repair().unwrap();
fs.reader.flush().unwrap();
}
cur.set_position(0);
let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
assert_eq!(fs.mdb.num_files, 2);
assert!(fs.fsck().unwrap().unwrap().is_clean());
}
#[test]
fn fsck_withholds_repair_on_broken_chain() {
let mut fs = MfsFilesystem::open(Cursor::new(build_test_mfs()), 0).unwrap();
fs.entries[0].data_first_block = 50; let res = fs.fsck().unwrap().unwrap();
assert!(res.errors.iter().any(|e| e.code == "MfsBrokenChain"));
assert!(!res.repairable, "a broken chain must not be repairable");
let report = fs.repair().unwrap();
assert!(report.fixes_applied.is_empty());
assert!(report.unrepairable_count >= 1);
}
#[test]
fn map_get_round_trip() {
let disk = build_test_mfs();
let cur = Cursor::new(disk);
let fs = MfsFilesystem::open(cur, 0).unwrap();
assert_eq!(fs.map_get(2), 1, "block 2 marked end-of-chain");
assert_eq!(fs.map_get(3), 1);
assert_eq!(fs.map_get(4), 1);
assert_eq!(fs.map_get(5), 0, "block 5 marked free");
assert_eq!(fs.map_get(0), 0, "reserved block 0");
assert_eq!(fs.map_get(1), 0, "reserved block 1");
}
#[test]
fn total_and_used_size_reflect_mdb_counts() {
let disk = build_test_mfs();
let cur = Cursor::new(disk);
let fs = MfsFilesystem::open(cur, 0).unwrap();
assert_eq!(fs.total_size(), 8 * 1024);
assert_eq!(fs.used_size(), 3 * 1024);
}
fn edit_fixture() -> Cursor<Vec<u8>> {
Cursor::new(build_test_mfs())
}
#[test]
fn create_file_writes_data_and_round_trips_through_sync() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let before_free = EditableFilesystem::free_space(&mut fs).unwrap();
let payload = b"freshly-created via EditableFilesystem".to_vec();
let mut src = Cursor::new(payload.clone());
let opts = CreateFileOptions::default();
let fe = fs
.create_file(&root, "NewFile", &mut src, payload.len() as u64, &opts)
.unwrap();
assert_eq!(fe.name, "NewFile");
assert_eq!(fe.size, payload.len() as u64);
fs.sync_metadata().unwrap();
let inner = fs.reader.clone();
let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
let root2 = fs2.root().unwrap();
let entries = fs2.list_directory(&root2).unwrap();
let new = entries.iter().find(|e| e.name == "NewFile").unwrap();
let read_back = fs2.read_file(new, 4096).unwrap();
assert_eq!(read_back, payload);
let after_free = EditableFilesystem::free_space(&mut fs2).unwrap();
assert_eq!(after_free, before_free - 1024);
}
#[test]
fn delete_entry_releases_blocks_and_clears_directory() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let before_free = EditableFilesystem::free_space(&mut fs).unwrap();
let entries = fs.list_directory(&root).unwrap();
let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
fs.delete_entry(&root, &doc).unwrap();
fs.sync_metadata().unwrap();
let inner = fs.reader.clone();
let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
let root2 = fs2.root().unwrap();
let entries = fs2.list_directory(&root2).unwrap();
assert!(
entries.iter().all(|e| e.name != "Doc"),
"Doc should be gone after delete"
);
let after_free = EditableFilesystem::free_space(&mut fs2).unwrap();
assert_eq!(after_free, before_free + 2 * 1024);
}
#[test]
fn rename_in_place_round_trips_through_sync() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
let fnum = doc.location;
fs.rename(&root, &doc, "Report").unwrap();
fs.sync_metadata().unwrap();
let inner = fs.reader.clone();
let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
let root2 = fs2.root().unwrap();
let entries2 = fs2.list_directory(&root2).unwrap();
let names: Vec<&str> = entries2.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"Report"));
assert!(!names.contains(&"Doc"));
let renamed = entries2.iter().find(|e| e.name == "Report").unwrap();
assert_eq!(renamed.location, fnum);
assert_eq!(renamed.resource_fork_size, Some(4));
assert_eq!(fs2.read_file(renamed, 1024).unwrap(), b"first line\n");
assert_eq!(fs2.read_resource_fork(renamed).unwrap(), b"RSRC");
}
#[test]
fn names_differing_only_in_case_are_the_same_file() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let err = fs
.create_file(
&root,
"hello",
&mut &b"x"[..],
1,
&CreateFileOptions::default(),
)
.unwrap_err();
assert!(format!("{err}").contains("already exists"), "{err}");
let entries = fs.list_directory(&root).unwrap();
let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
let err = fs.rename(&root, &doc, "HELLO").unwrap_err();
assert!(matches!(err, FilesystemError::AlreadyExists(_)));
fs.rename(&root, &doc, "DOC").unwrap();
}
#[test]
fn rename_rejects_collision() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
let err = fs.rename(&root, &doc, "Hello").unwrap_err();
assert!(matches!(err, FilesystemError::AlreadyExists(_)));
}
#[test]
fn create_file_rejects_duplicate_names() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let mut src = Cursor::new(b"x".to_vec());
let err = fs
.create_file(&root, "Hello", &mut src, 1, &CreateFileOptions::default())
.unwrap_err();
assert!(matches!(err, FilesystemError::InvalidData(_)));
}
#[test]
fn create_file_rejects_oversize_data() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let huge = vec![0u8; 8 * 1024];
let mut src = Cursor::new(huge);
let err = fs
.create_file(
&root,
"TooBig",
&mut src,
8 * 1024,
&CreateFileOptions::default(),
)
.unwrap_err();
assert!(matches!(err, FilesystemError::InvalidData(_)));
}
#[test]
fn create_then_delete_returns_to_original_free_count() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let before = EditableFilesystem::free_space(&mut fs).unwrap();
let mut src = Cursor::new(b"abc".to_vec());
let fe = fs
.create_file(&root, "Tmp", &mut src, 3, &CreateFileOptions::default())
.unwrap();
let mid = EditableFilesystem::free_space(&mut fs).unwrap();
assert_eq!(mid, before - 1024);
fs.delete_entry(&root, &fe).unwrap();
let after = EditableFilesystem::free_space(&mut fs).unwrap();
assert_eq!(after, before, "free count must return after create+delete");
}
#[test]
fn create_directory_is_unsupported() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let err = fs
.create_directory(&root, "subdir", &CreateDirectoryOptions::default())
.unwrap_err();
assert!(matches!(err, FilesystemError::Unsupported(_)));
}
#[test]
fn set_type_creator_updates_finder_info_and_persists() {
let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
let root = fs.root().unwrap();
let entries = fs.list_directory(&root).unwrap();
let hello = entries.iter().find(|e| e.name == "Hello").unwrap().clone();
fs.set_type_creator(&hello, "PICT", "8BIM").unwrap();
fs.sync_metadata().unwrap();
let inner = fs.reader.clone();
let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
let root2 = fs2.root().unwrap();
let entries = fs2.list_directory(&root2).unwrap();
let hello2 = entries.iter().find(|e| e.name == "Hello").unwrap();
assert_eq!(hello2.type_code, Some(*b"PICT"));
assert_eq!(hello2.creator_code, Some(*b"8BIM"));
}
}
#[cfg(test)]
mod physical_length_tests {
use super::*;
use disk_core::filesystem::{CreateFileOptions, Filesystem};
use std::io::Cursor;
#[test]
fn physical_lengths_are_whole_allocation_blocks() {
let img = create_blank_mfs(400 * 1024, "T").unwrap();
let mut fs = MfsFilesystem::open(Cursor::new(img), 0).unwrap();
let root = fs.root().unwrap();
let mut data = Cursor::new(vec![0x5Au8; 100]);
fs.create_file(&root, "tiny", &mut data, 100, &CreateFileOptions::default())
.unwrap();
fs.sync_metadata().unwrap();
let abs = fs.mdb.alloc_block_size;
let off = fs.partition_offset + fs.mdb.dir_start_sector as u64 * 512;
fs.reader.seek(SeekFrom::Start(off)).unwrap();
let mut sector = [0u8; 512];
fs.reader.read_exact(&mut sector).unwrap();
assert_ne!(sector[0] & 0x80, 0, "first directory entry is in use");
assert_eq!(
BigEndian::read_u32(§or[24..28]),
100,
"logical data length"
);
assert_eq!(
BigEndian::read_u32(§or[28..32]),
abs,
"physical data length"
);
assert_eq!(BigEndian::read_u32(§or[38..42]), 0, "no resource fork");
}
}