Skip to main content

fs_mac/
mfs.rs

1//! Macintosh File System (MFS) — the pre-HFS filesystem shipped on 400 KB
2//! single-sided 3.5" floppies for the original Mac, Mac 512K, and the
3//! Macintosh Plus (until System 3.0 brought HFS).
4//!
5//! Used by the MiSTer **MacPlus** core when a `.dsk` is a 400 KB MFS
6//! floppy rather than an HFS volume. The plan ([`docs/mister_filesystem_
7//! implementation_plan.md`] Wave 1) targets an **extract-only floor**:
8//! browse + read data fork (+ surface resource-fork size). Add/Delete and
9//! resize are intentionally out of scope — the core itself rarely writes
10//! MFS, and `BasiliskII` is the natural authoring path when one does.
11//!
12//! ## On-disk layout (Inside Macintosh: Files, Volume II, ch. 2)
13//!
14//! - **Sectors 0..1** (offset 0..1024) — boot blocks (zeroed on most non-
15//!   bootable disks)
16//! - **Sector 2** (offset 1024) — Master Directory Block (MDB):
17//!
18//! ```text
19//! 0x00  u16   drSigWord     0xD2D7  (MFS magic)
20//! 0x02  u32   drCrDate      create date (Mac epoch 1904-01-01)
21//! 0x06  u32   drLsBkUp      last backup date
22//! 0x0A  u16   drAtrb        volume attributes
23//! 0x0C  u16   drNmFls       number of files
24//! 0x0E  u16   drDirSt       first logical (512 B) block of the file directory
25//! 0x10  u16   drBlLen       length of file directory in logical blocks
26//! 0x12  u16   drNmAlBlks    number of allocation blocks
27//! 0x14  u32   drAlBlkSiz    allocation-block size in bytes
28//! 0x18  u32   drClpSiz      clump size (unused for extract)
29//! 0x1C  u16   drAlBlSt      first allocation block in logical sectors
30//! 0x1E  u32   drNxtFNum     next free file number
31//! 0x22  u16   drFreeBks     free allocation blocks
32//! 0x24  pstr  drVN          volume name (Pascal string, ≤27 chars)
33//! ```
34//!
35//! - **Volume map** — starts immediately after the volume-name Pascal
36//!   string and continues into subsequent sectors as needed. 12 bits per
37//!   allocation block:
38//!     - entries 0, 1 reserved
39//!     - entry N ≥ 2 = pointer to next allocation block in the file's
40//!       chain, or `0` (free) or `1` (last block of chain)
41//!
42//! - **File Directory** — `drBlLen` × 512 B sectors starting at logical
43//!   sector `drDirSt`. Variable-length entries (rounded to even):
44//!
45//! ```text
46//! 0x00  u8    flFlags       bit7 set = entry used; bit0 = locked
47//! 0x01  u8    flTyp         version (=0)
48//! 0x02  16    flUsrWds      Finder info (type/creator at +0/+4)
49//! 0x12  u32   flFlNum       file number
50//! 0x16  u16   flStBlk       first data-fork allocation block (0 = empty)
51//! 0x18  u32   flLgLen       logical data-fork length
52//! 0x1C  u32   flPyLen       physical data-fork length (rounded)
53//! 0x20  u16   flRStBlk      first resource-fork allocation block
54//! 0x22  u32   flRLgLen      logical resource-fork length
55//! 0x26  u32   flRPyLen      physical resource-fork length
56//! 0x2A  u32   flCrDat       create date
57//! 0x2E  u32   flMdDat       modify date
58//! 0x32  pstr  flNam         Pascal string filename (≤255 in spec; in
59//!                           practice ≤63 because System 1.0 limited it)
60//! ```
61//!
62//! Entry length = `0x32 + 1 + name_len`, padded up to the next even
63//! offset. An entry where `flFlags & 0x80 == 0` ends the directory in
64//! its sector (and the next sector starts a fresh entry).
65//!
66//! All multi-byte fields are **big-endian** (m68k native).
67
68#[cfg(feature = "rust173-polyfill")]
69use disk_core::rust173::IntIsMultipleOf as _;
70use std::io::{Read, Seek, SeekFrom, Write};
71
72use byteorder::{BigEndian, ByteOrder};
73
74use disk_core::entry::{EntryType, FileEntry};
75use disk_core::filesystem::{
76    CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem, FilesystemError,
77};
78use disk_core::fsck::{FsckIssue, FsckResult, FsckStats, RepairReport};
79
80/// MFS magic — big-endian u16 at byte 1024.
81pub const MFS_SIGNATURE: u16 = 0xD2D7;
82
83/// MDB offset within the volume.
84const MDB_OFFSET: u64 = 1024;
85
86/// 400 KB single-sided 3.5" floppy — the canonical MFS volume size.
87pub const MFS_400K_BYTES: u64 = 409_600;
88
89/// 800 KB double-sided 3.5" floppy — supported by MFS too, though rare.
90pub const MFS_800K_BYTES: u64 = 819_200;
91
92/// Maximum allocation blocks per the on-disk u16 field.
93const MAX_ALLOC_BLOCKS: u32 = 0xFFFF;
94
95/// Maximum allocation-block chain length we walk while reading one fork.
96/// MFS volumes are tiny (≤ 800 KB) so this is generous; the cap is a
97/// runaway guard, not a real limit.
98const MAX_ALLOC_CHAIN: usize = 4096;
99
100/// Parsed MDB fields the rest of the module needs. Stored on the FS so
101/// every list/read call can use them without re-reading the boot sector.
102#[derive(Debug, Clone)]
103pub struct MfsMdb {
104    pub create_date: u32,
105    pub last_backup_date: u32,
106    pub volume_attributes: u16,
107    pub num_files: u16,
108    pub dir_start_sector: u16,
109    pub dir_length_sectors: u16,
110    pub num_alloc_blocks: u16,
111    pub alloc_block_size: u32,
112    pub clump_size: u32,
113    pub first_alloc_block_sector: u16,
114    pub next_file_number: u32,
115    pub free_blocks: u16,
116    pub volume_name: String,
117    /// Volume-map base byte offset within the partition (right after the
118    /// MDB header + volume name Pascal string).
119    map_offset: u64,
120}
121
122impl MfsMdb {
123    /// Parse from a 512-byte buffer assumed to be the MDB sector.
124    fn parse(buf: &[u8; 512]) -> Result<Self, FilesystemError> {
125        let sig = BigEndian::read_u16(&buf[0..2]);
126        if sig != MFS_SIGNATURE {
127            return Err(FilesystemError::InvalidData(format!(
128                "bad MFS signature: 0x{sig:04X} (expected 0x{MFS_SIGNATURE:04X})"
129            )));
130        }
131
132        let create_date = BigEndian::read_u32(&buf[2..6]);
133        let last_backup_date = BigEndian::read_u32(&buf[6..10]);
134        let volume_attributes = BigEndian::read_u16(&buf[10..12]);
135        let num_files = BigEndian::read_u16(&buf[12..14]);
136        let dir_start_sector = BigEndian::read_u16(&buf[14..16]);
137        let dir_length_sectors = BigEndian::read_u16(&buf[16..18]);
138        let num_alloc_blocks = BigEndian::read_u16(&buf[18..20]);
139        let alloc_block_size = BigEndian::read_u32(&buf[20..24]);
140        let clump_size = BigEndian::read_u32(&buf[24..28]);
141        let first_alloc_block_sector = BigEndian::read_u16(&buf[28..30]);
142        let next_file_number = BigEndian::read_u32(&buf[30..34]);
143        let free_blocks = BigEndian::read_u16(&buf[34..36]);
144
145        // Volume name: Pascal string at offset 36 (1 length byte + chars).
146        let name_len = buf[36] as usize;
147        if 37 + name_len > 64 {
148            // Spec caps Pascal-string name length at 27 in MFS; we tolerate
149            // up to 27, defensively reject anything past 64 - 37 = 27 bytes.
150            return Err(FilesystemError::InvalidData(format!(
151                "MFS volume name length {name_len} exceeds 27"
152            )));
153        }
154        let volume_name = crate::hfs::mac_roman_to_utf8(&buf[37..37 + name_len]);
155
156        // The volume allocation block map begins at a *fixed* offset 64 within
157        // the MDB block (the volume-name field is fixed-width: 1 length byte +
158        // 27 reserved chars = bytes 36..63, map at 64). The bit-packed map
159        // continues into subsequent sectors when num_alloc_blocks * 12 bits
160        // overflows. (An earlier version placed it right after the actual name,
161        // which only coincides with 64 for a full 27-char name and corrupted
162        // every real disk with a shorter name.)
163        let map_offset = MDB_OFFSET + 64;
164
165        // Sanity: alloc_block_size must be a positive multiple of 512.
166        if alloc_block_size == 0 || alloc_block_size % 512 != 0 {
167            return Err(FilesystemError::InvalidData(format!(
168                "MFS allocation block size {alloc_block_size} is not a positive multiple of 512"
169            )));
170        }
171        if num_alloc_blocks as u32 > MAX_ALLOC_BLOCKS {
172            return Err(FilesystemError::InvalidData(format!(
173                "MFS allocation-block count {num_alloc_blocks} exceeds 65535"
174            )));
175        }
176        if dir_length_sectors == 0 {
177            return Err(FilesystemError::InvalidData(
178                "MFS file directory length is 0".into(),
179            ));
180        }
181
182        Ok(MfsMdb {
183            create_date,
184            last_backup_date,
185            volume_attributes,
186            num_files,
187            dir_start_sector,
188            dir_length_sectors,
189            num_alloc_blocks,
190            alloc_block_size,
191            clump_size,
192            first_alloc_block_sector,
193            next_file_number,
194            free_blocks,
195            volume_name,
196            map_offset,
197        })
198    }
199}
200
201/// One File Directory entry, decoded.
202#[derive(Debug, Clone)]
203#[allow(dead_code)] // dates + finder_info kept for round-trip / future write
204struct MfsDirEntry {
205    /// Bit 7 = entry is in use; bit 0 = file locked.
206    flags: u8,
207    /// 16-byte Finder info — type at +0, creator at +4.
208    finder_info: [u8; 16],
209    file_number: u32,
210    data_first_block: u16,
211    data_logical_length: u32,
212    rsrc_first_block: u16,
213    rsrc_logical_length: u32,
214    create_date: u32,
215    modify_date: u32,
216    name: String,
217}
218
219impl MfsDirEntry {
220    fn is_in_use(&self) -> bool {
221        self.flags & 0x80 != 0
222    }
223
224    /// Finder flags (`FInfo.fdFlags`) — the big-endian 2-byte field at offset 8
225    /// of the 16-byte Finder info. Surfaced on `FileEntry` so copy /
226    /// `get-binhex` round-trips preserve them.
227    fn finder_flags(&self) -> u16 {
228        u16::from_be_bytes([self.finder_info[8], self.finder_info[9]])
229    }
230}
231
232/// Live MFS reader. Holds the parsed MDB, the volume bitmap, and a
233/// re-decoded directory cache (small — at most a few hundred entries on
234/// a 400 KB floppy).
235pub struct MfsFilesystem<R: Read + Seek + Send> {
236    reader: R,
237    partition_offset: u64,
238    pub(crate) mdb: MfsMdb,
239    /// Cached directory entries, keyed by file number for O(1) lookup
240    /// from `read_file`.
241    entries: Vec<MfsDirEntry>,
242    /// Allocation-block-map bytes (12-bit packed). Cached on open.
243    map_bytes: Vec<u8>,
244}
245
246impl<R: Read + Seek + Send> MfsFilesystem<R> {
247    /// Open an MFS volume at the given partition offset.
248    pub fn open(mut reader: R, partition_offset: u64) -> Result<Self, FilesystemError> {
249        reader.seek(SeekFrom::Start(partition_offset + MDB_OFFSET))?;
250        let mut mdb_buf = [0u8; 512];
251        reader.read_exact(&mut mdb_buf)?;
252        let mdb = MfsMdb::parse(&mdb_buf)?;
253
254        // Read the allocation-block map. We need (num_alloc_blocks * 12 + 7) / 8
255        // bytes starting at `mdb.map_offset`, possibly straddling sectors.
256        let map_bits = mdb.num_alloc_blocks as usize * 12;
257        let map_byte_len = map_bits.div_ceil(8);
258        let mut map_bytes = vec![0u8; map_byte_len];
259        reader.seek(SeekFrom::Start(partition_offset + mdb.map_offset))?;
260        reader.read_exact(&mut map_bytes)?;
261
262        // Read the file directory.
263        let mut entries = Vec::new();
264        for sector_idx in 0..mdb.dir_length_sectors {
265            let sector_no = mdb.dir_start_sector as u64 + sector_idx as u64;
266            let sector_off = partition_offset + sector_no * 512;
267            reader.seek(SeekFrom::Start(sector_off))?;
268            let mut sec = [0u8; 512];
269            if reader.read_exact(&mut sec).is_err() {
270                break;
271            }
272            parse_dir_sector(&sec, &mut entries);
273        }
274
275        Ok(MfsFilesystem {
276            reader,
277            partition_offset,
278            mdb,
279            entries,
280            map_bytes,
281        })
282    }
283
284    /// Look up entry index by file number.
285    fn entry_by_file_number(&self, fnum: u32) -> Option<&MfsDirEntry> {
286        self.entries
287            .iter()
288            .find(|e| e.is_in_use() && e.file_number == fnum)
289    }
290
291    /// Look up a 12-bit volume-map entry. Returns 0 if `block` is out of
292    /// range (allocation blocks 0 and 1 are reserved and always read as 0).
293    fn map_get(&self, block: u16) -> u16 {
294        if block < 2 {
295            return 0;
296        }
297        // Map entry index 0 corresponds to allocation block 2, so the entry for
298        // block N lives at bit (N - 2) * 12. Entries are 12-bit, high-order
299        // first; two pack into three bytes.
300        let bit_off = (block as usize - 2) * 12;
301        let byte_off = bit_off / 8;
302        if byte_off + 1 >= self.map_bytes.len() {
303            return 0;
304        }
305        // Entry-index parity equals block parity (index = block - 2), so the
306        // nibble alignment can still key off `block`.
307        if block.is_multiple_of(2) {
308            // even block: high nibble of byte_off (which is block*12/8) is the LOW
309            // 4 bits of the entry, byte_off+1 high 8 bits... Actually it's
310            // simpler: 12 bits big-endian-packed: bits b*12 .. b*12+11 of the map.
311            let hi = self.map_bytes[byte_off] as u16;
312            let lo = (self.map_bytes[byte_off + 1] >> 4) as u16;
313            (hi << 4) | lo
314        } else {
315            let hi = (self.map_bytes[byte_off] & 0x0F) as u16;
316            let lo = self.map_bytes[byte_off + 1] as u16;
317            (hi << 8) | lo
318        }
319    }
320
321    /// Byte offset of allocation block N in the volume.
322    fn alloc_block_offset(&self, block: u16) -> u64 {
323        // Allocation block 2 maps to logical sector `first_alloc_block_sector`.
324        // Block N maps to logical sector first_alloc_block_sector + (N - 2) * (alloc_block_size / 512).
325        debug_assert!(block >= 2);
326        let sectors_per_block = (self.mdb.alloc_block_size / 512) as u64;
327        let sector =
328            self.mdb.first_alloc_block_sector as u64 + (block as u64 - 2) * sectors_per_block;
329        sector * 512
330    }
331
332    /// Walk an allocation-block chain starting at `first` and read
333    /// `logical_len` bytes. Returns the chained bytes (truncated to
334    /// `logical_len`).
335    fn read_chain(&mut self, first: u16, logical_len: u32) -> Result<Vec<u8>, FilesystemError> {
336        if first == 0 || logical_len == 0 {
337            return Ok(Vec::new());
338        }
339        let mut data = Vec::with_capacity(logical_len as usize);
340        let bs = self.mdb.alloc_block_size as usize;
341        let mut block = first;
342        for _hop in 0..MAX_ALLOC_CHAIN {
343            if block < 2 || block as u32 - 2 >= self.mdb.num_alloc_blocks as u32 {
344                return Err(FilesystemError::InvalidData(format!(
345                    "MFS chain references out-of-range alloc block {block}"
346                )));
347            }
348            let off = self.partition_offset + self.alloc_block_offset(block);
349            self.reader.seek(SeekFrom::Start(off))?;
350            let want = (logical_len as usize).saturating_sub(data.len()).min(bs);
351            let mut chunk = vec![0u8; bs];
352            self.reader.read_exact(&mut chunk)?;
353            data.extend_from_slice(&chunk[..want]);
354            if data.len() >= logical_len as usize {
355                break;
356            }
357            let next = self.map_get(block);
358            if next == 1 {
359                // End-of-chain. Data may be shorter than logical_len if the
360                // catalog is inconsistent; truncate to what we have.
361                break;
362            }
363            if next == 0 {
364                return Err(FilesystemError::InvalidData(format!(
365                    "MFS chain hits a free block at {block}"
366                )));
367            }
368            block = next;
369        }
370        Ok(data)
371    }
372
373    /// Read the resource fork of a directory entry. Returns an empty `Vec`
374    /// if the file has no resource fork. Surfaced as a side channel
375    /// because the trait's `read_file` only exposes the data fork; classic
376    /// Mac apps often live in the resource fork and consumers (e.g. the
377    /// `mac_archive` exporter) need access.
378    pub fn read_resource_fork(&mut self, entry: &FileEntry) -> Result<Vec<u8>, FilesystemError> {
379        let fnum = entry.location as u32;
380        let de = self
381            .entry_by_file_number(fnum)
382            .ok_or_else(|| FilesystemError::NotFound(format!("file number {fnum} not found")))?
383            .clone();
384        self.read_chain(de.rsrc_first_block, de.rsrc_logical_length)
385    }
386}
387
388/// Parse every used entry out of one 512-byte directory sector. Stops at
389/// the first unused entry (per spec, an unused entry terminates the
390/// current sector's entry list).
391fn parse_dir_sector(sec: &[u8; 512], out: &mut Vec<MfsDirEntry>) {
392    let mut off = 0usize;
393    while off + 50 < sec.len() {
394        let flags = sec[off];
395        if flags & 0x80 == 0 {
396            // Unused entry — terminates the sector per spec.
397            break;
398        }
399        let mut finder_info = [0u8; 16];
400        finder_info.copy_from_slice(&sec[off + 2..off + 18]);
401        let file_number = BigEndian::read_u32(&sec[off + 18..off + 22]);
402        let data_first_block = BigEndian::read_u16(&sec[off + 22..off + 24]);
403        let data_logical_length = BigEndian::read_u32(&sec[off + 24..off + 28]);
404        let _data_phys_length = BigEndian::read_u32(&sec[off + 28..off + 32]);
405        let rsrc_first_block = BigEndian::read_u16(&sec[off + 32..off + 34]);
406        let rsrc_logical_length = BigEndian::read_u32(&sec[off + 34..off + 38]);
407        let _rsrc_phys_length = BigEndian::read_u32(&sec[off + 38..off + 42]);
408        let create_date = BigEndian::read_u32(&sec[off + 42..off + 46]);
409        let modify_date = BigEndian::read_u32(&sec[off + 46..off + 50]);
410        let name_len = sec[off + 50] as usize;
411        if off + 51 + name_len > sec.len() {
412            // Truncated entry — stop here.
413            break;
414        }
415        let name = crate::hfs::mac_roman_to_utf8(&sec[off + 51..off + 51 + name_len]);
416
417        out.push(MfsDirEntry {
418            flags,
419            finder_info,
420            file_number,
421            data_first_block,
422            data_logical_length,
423            rsrc_first_block,
424            rsrc_logical_length,
425            create_date,
426            modify_date,
427            name,
428        });
429
430        // Advance past header + name (Pascal byte + chars) padded to even.
431        let entry_len = 51 + name_len;
432        let padded = entry_len + (entry_len & 1);
433        off += padded;
434    }
435}
436
437/// Maximum length of an MFS filename in bytes (Mac Roman). The on-disk
438/// field is a 28-byte Pascal string, so 1 length byte + up to 27 chars.
439pub const MFS_MAX_NAME_LEN: usize = 27;
440
441/// MFS name equality as the File Manager sees it: Mac Roman bytes compared
442/// through the HFS case-folding order, so "Doc" and "DOC" are the same file.
443fn mfs_names_equal(a: &str, b: &str) -> bool {
444    let a_bytes = crate::hfs::utf8_to_mac_roman(a).unwrap_or_else(|_| a.as_bytes().to_vec());
445    let b_bytes = crate::hfs::utf8_to_mac_roman(b).unwrap_or_else(|_| b.as_bytes().to_vec());
446    crate::hfs_common::compare_hfs_keys(0, &a_bytes, 0, &b_bytes) == std::cmp::Ordering::Equal
447}
448
449/// Validate a candidate filename for `create_file` / `set_type_creator`.
450/// MFS uses Mac Roman encoding and disallows the path-separator ':' (a
451/// Classic Mac OS convention also enforced by HFS).
452fn validate_mfs_name(name: &str) -> Result<Vec<u8>, FilesystemError> {
453    if name.is_empty() {
454        return Err(FilesystemError::InvalidData("MFS filename is empty".into()));
455    }
456    if name.contains(':') {
457        return Err(FilesystemError::InvalidData(
458            "MFS filename contains ':' (Mac path separator) — use '-' or '_'".into(),
459        ));
460    }
461    let bytes = crate::hfs::utf8_to_mac_roman(name).map_err(|_| {
462        FilesystemError::InvalidData(
463            "MFS filename contains characters not representable in Mac Roman".into(),
464        )
465    })?;
466    if bytes.len() > MFS_MAX_NAME_LEN {
467        return Err(FilesystemError::InvalidData(format!(
468            "MFS filename is {} bytes; max is {MFS_MAX_NAME_LEN}",
469            bytes.len()
470        )));
471    }
472    Ok(bytes)
473}
474
475impl MfsDirEntry {
476    /// Serialize a single directory entry into a fresh `Vec<u8>` padded to
477    /// even length (the on-disk constraint — entries align to even byte
478    /// offsets within a directory sector). Caller stamps the result into
479    /// the sector buffer.
480    fn encode(&self, alloc_block_size: u32) -> Vec<u8> {
481        // flPyLen / flRPyLen are the allocated sizes: whole allocation blocks.
482        let physical = |logical: u32| -> u32 {
483            if logical == 0 || alloc_block_size == 0 {
484                0
485            } else {
486                logical
487                    .div_ceil(alloc_block_size)
488                    .saturating_mul(alloc_block_size)
489            }
490        };
491        let name_bytes = crate::hfs::utf8_to_mac_roman(&self.name).unwrap_or_default();
492        let name_len = name_bytes.len().min(MFS_MAX_NAME_LEN) as u8;
493        let total = 51 + name_len as usize;
494        let padded = total + (total & 1);
495        let mut buf = vec![0u8; padded];
496        buf[0] = self.flags;
497        // byte 1 = version (=0); already zeroed
498        buf[2..18].copy_from_slice(&self.finder_info);
499        BigEndian::write_u32(&mut buf[18..22], self.file_number);
500        BigEndian::write_u16(&mut buf[22..24], self.data_first_block);
501        BigEndian::write_u32(&mut buf[24..28], self.data_logical_length);
502        BigEndian::write_u32(&mut buf[28..32], physical(self.data_logical_length));
503        BigEndian::write_u16(&mut buf[32..34], self.rsrc_first_block);
504        BigEndian::write_u32(&mut buf[34..38], self.rsrc_logical_length);
505        BigEndian::write_u32(&mut buf[38..42], physical(self.rsrc_logical_length));
506        BigEndian::write_u32(&mut buf[42..46], self.create_date);
507        BigEndian::write_u32(&mut buf[46..50], self.modify_date);
508        buf[50] = name_len;
509        buf[51..51 + name_len as usize].copy_from_slice(&name_bytes[..name_len as usize]);
510        buf
511    }
512}
513
514impl<R: Read + Write + Seek + Send> MfsFilesystem<R> {
515    // --- low-level block-map editing ---------------------------------------
516
517    /// Set a 12-bit volume-map entry to `value`. Mirrors `map_get`'s
518    /// high-nibble-first packing. Panics on out-of-range `block`.
519    fn map_set(&mut self, block: u16, value: u16) {
520        debug_assert!(
521            block as u32 >= 2 && (block as u32) < self.mdb.num_alloc_blocks as u32 + 2,
522            "map_set out-of-range block {block}"
523        );
524        let value = value & 0x0FFF;
525        // Entry for block N is at bit (N - 2) * 12 (index 0 == block 2).
526        let bit_off = (block as usize - 2) * 12;
527        let byte_off = bit_off / 8;
528        if block.is_multiple_of(2) {
529            self.map_bytes[byte_off] = (value >> 4) as u8;
530            self.map_bytes[byte_off + 1] =
531                (self.map_bytes[byte_off + 1] & 0x0F) | (((value & 0x0F) as u8) << 4);
532        } else {
533            self.map_bytes[byte_off] =
534                (self.map_bytes[byte_off] & 0xF0) | ((value >> 8) as u8 & 0x0F);
535            self.map_bytes[byte_off + 1] = (value & 0xFF) as u8;
536        }
537    }
538
539    /// Find `count` free allocation blocks, link them into a chain
540    /// (block_i -> block_{i+1}, last block -> 1 = end-of-chain), and
541    /// return the first block number. Bumps `mdb.free_blocks` down.
542    fn alloc_chain(&mut self, count: u32) -> Result<u16, FilesystemError> {
543        if count == 0 {
544            return Ok(0);
545        }
546        if (count as u16) > self.mdb.free_blocks {
547            return Err(FilesystemError::InvalidData(format!(
548                "out of space: need {count} alloc blocks, have {} free",
549                self.mdb.free_blocks
550            )));
551        }
552        // First pass: gather the indices of `count` free blocks.
553        let mut picked = Vec::with_capacity(count as usize);
554        // Allocation blocks are numbered starting at 2 (blocks 0 and 1
555        // reserved by the spec). num_alloc_blocks counts the
556        // user-addressable blocks, so valid range is 2 ..= num_alloc_blocks + 1.
557        for b in 2..(self.mdb.num_alloc_blocks as u32 + 2) {
558            if self.map_get(b as u16) == 0 {
559                picked.push(b as u16);
560                if picked.len() == count as usize {
561                    break;
562                }
563            }
564        }
565        if picked.len() < count as usize {
566            return Err(FilesystemError::InvalidData(format!(
567                "fragmentation: {} free per MDB but only {} blocks found in map",
568                self.mdb.free_blocks,
569                picked.len()
570            )));
571        }
572        // Second pass: link them.
573        for w in picked.windows(2) {
574            let cur = w[0];
575            let nxt = w[1];
576            self.map_set(cur, nxt);
577        }
578        // Last block: end-of-chain marker (1).
579        let last = *picked.last().expect("picked non-empty");
580        self.map_set(last, 1);
581        self.mdb.free_blocks -= count as u16;
582        Ok(picked[0])
583    }
584
585    /// Walk an allocation-block chain starting at `first` and mark every
586    /// block free (map entry = 0). Bumps `mdb.free_blocks` up by the
587    /// number of blocks released.
588    fn free_chain(&mut self, first: u16) -> Result<(), FilesystemError> {
589        if first == 0 {
590            return Ok(());
591        }
592        let mut block = first;
593        let mut visited = std::collections::HashSet::new();
594        for _hop in 0..MAX_ALLOC_CHAIN {
595            if block < 2 || block as u32 - 2 >= self.mdb.num_alloc_blocks as u32 {
596                return Err(FilesystemError::InvalidData(format!(
597                    "free_chain hit out-of-range block {block}"
598                )));
599            }
600            if !visited.insert(block) {
601                return Err(FilesystemError::InvalidData(format!(
602                    "free_chain cycle at block {block}"
603                )));
604            }
605            let next = self.map_get(block);
606            self.map_set(block, 0);
607            self.mdb.free_blocks += 1;
608            if next == 1 || next == 0 {
609                return Ok(());
610            }
611            block = next;
612        }
613        Err(FilesystemError::InvalidData(
614            "free_chain ran past MAX_ALLOC_CHAIN".into(),
615        ))
616    }
617
618    /// Allocate an allocation-block chain for `bytes` and write it, returning
619    /// `(first_block, logical_len)`. An empty fork allocates nothing and returns
620    /// `(0, 0)`. Shared by `create_file` (both forks) and `write_resource_fork`.
621    fn alloc_and_write_fork(&mut self, bytes: &[u8]) -> Result<(u16, u32), FilesystemError> {
622        let bs = self.mdb.alloc_block_size as usize;
623        let count = if bytes.is_empty() {
624            0
625        } else {
626            bytes.len().div_ceil(bs) as u32
627        };
628        let first = self.alloc_chain(count)?;
629        if first != 0 {
630            self.write_data_chain(first, bytes)?;
631        }
632        Ok((first, bytes.len() as u32))
633    }
634
635    /// Write `data` across the allocation-block chain starting at `first`.
636    /// The chain must have been pre-allocated by `alloc_chain`. Each block
637    /// holds `mdb.alloc_block_size` bytes; the last block is zero-padded
638    /// to its full size.
639    fn write_data_chain(&mut self, first: u16, data: &[u8]) -> Result<(), FilesystemError> {
640        if first == 0 || data.is_empty() {
641            return Ok(());
642        }
643        let bs = self.mdb.alloc_block_size as usize;
644        let mut written = 0usize;
645        let mut block = first;
646        for _hop in 0..MAX_ALLOC_CHAIN {
647            let chunk_off = written;
648            let chunk_end = (written + bs).min(data.len());
649            let abs = self.partition_offset + self.alloc_block_offset(block);
650            self.reader.seek(SeekFrom::Start(abs))?;
651            let chunk = &data[chunk_off..chunk_end];
652            self.reader.write_all(chunk)?;
653            // Zero-pad the rest of the block.
654            if chunk.len() < bs {
655                let pad = vec![0u8; bs - chunk.len()];
656                self.reader.write_all(&pad)?;
657            }
658            written = chunk_end;
659            if written >= data.len() {
660                return Ok(());
661            }
662            let next = self.map_get(block);
663            if next == 1 || next == 0 {
664                if written < data.len() {
665                    return Err(FilesystemError::InvalidData(format!(
666                        "write_data_chain short-chained: {} bytes unwritten",
667                        data.len() - written
668                    )));
669                }
670                return Ok(());
671            }
672            block = next;
673        }
674        Err(FilesystemError::InvalidData(
675            "write_data_chain ran past MAX_ALLOC_CHAIN".into(),
676        ))
677    }
678
679    // --- on-disk write-back -----------------------------------------------
680
681    /// Re-encode the in-memory `entries` Vec into the directory sectors
682    /// and write them out. Used by `sync_metadata`.
683    fn dir_write_back(&mut self) -> Result<(), FilesystemError> {
684        let sector_count = self.mdb.dir_length_sectors as usize;
685        let mut packed = vec![vec![0u8; 512]; sector_count];
686        let mut sector_idx = 0usize;
687        let mut byte_off = 0usize;
688        for e in self.entries.iter().filter(|e| e.is_in_use()) {
689            let bytes = e.encode(self.mdb.alloc_block_size);
690            // If this entry doesn't fit in the remainder of the current
691            // sector (after leaving 1 byte for the terminator), advance.
692            if byte_off + bytes.len() + 1 > 512 {
693                sector_idx += 1;
694                byte_off = 0;
695                if sector_idx >= sector_count {
696                    return Err(FilesystemError::InvalidData(format!(
697                        "directory full: {} sectors of {sector_count} can't hold {} entries",
698                        sector_count,
699                        self.entries.iter().filter(|e| e.is_in_use()).count()
700                    )));
701                }
702            }
703            packed[sector_idx][byte_off..byte_off + bytes.len()].copy_from_slice(&bytes);
704            byte_off += bytes.len();
705        }
706        for (i, sec) in packed.into_iter().enumerate() {
707            let sector_no = self.mdb.dir_start_sector as u64 + i as u64;
708            let off = self.partition_offset + sector_no * 512;
709            self.reader.seek(SeekFrom::Start(off))?;
710            self.reader.write_all(&sec)?;
711        }
712        Ok(())
713    }
714
715    /// Serialize MDB fields back into a 512-byte buffer and write.
716    fn mdb_write_back(&mut self) -> Result<(), FilesystemError> {
717        // Re-read the original MDB sector to preserve any spare bytes we
718        // don't model (e.g. the post-volume-name volume-map area, which
719        // we write separately via `map_write_back`).
720        self.reader
721            .seek(SeekFrom::Start(self.partition_offset + MDB_OFFSET))?;
722        let mut buf = [0u8; 512];
723        self.reader.read_exact(&mut buf)?;
724        // Re-stamp the fields we own.
725        BigEndian::write_u16(&mut buf[0..2], MFS_SIGNATURE);
726        BigEndian::write_u32(&mut buf[2..6], self.mdb.create_date);
727        BigEndian::write_u32(&mut buf[6..10], self.mdb.last_backup_date);
728        BigEndian::write_u16(&mut buf[10..12], self.mdb.volume_attributes);
729        BigEndian::write_u16(&mut buf[12..14], self.mdb.num_files);
730        BigEndian::write_u16(&mut buf[14..16], self.mdb.dir_start_sector);
731        BigEndian::write_u16(&mut buf[16..18], self.mdb.dir_length_sectors);
732        BigEndian::write_u16(&mut buf[18..20], self.mdb.num_alloc_blocks);
733        BigEndian::write_u32(&mut buf[20..24], self.mdb.alloc_block_size);
734        BigEndian::write_u32(&mut buf[24..28], self.mdb.clump_size);
735        BigEndian::write_u16(&mut buf[28..30], self.mdb.first_alloc_block_sector);
736        BigEndian::write_u32(&mut buf[30..34], self.mdb.next_file_number);
737        BigEndian::write_u16(&mut buf[34..36], self.mdb.free_blocks);
738        let name_bytes = crate::hfs::utf8_to_mac_roman(&self.mdb.volume_name).unwrap_or_default();
739        let name_len = name_bytes.len().min(MFS_MAX_NAME_LEN);
740        buf[36] = name_len as u8;
741        // Zero the old name area before stamping the new one.
742        for b in buf[37..37 + MFS_MAX_NAME_LEN].iter_mut() {
743            *b = 0;
744        }
745        buf[37..37 + name_len].copy_from_slice(&name_bytes[..name_len]);
746        self.reader
747            .seek(SeekFrom::Start(self.partition_offset + MDB_OFFSET))?;
748        self.reader.write_all(&buf)?;
749        Ok(())
750    }
751
752    /// Write the in-memory `map_bytes` back to its on-disk position
753    /// (right after the volume name Pascal string within the MDB sector,
754    /// possibly straddling subsequent sectors).
755    fn map_write_back(&mut self) -> Result<(), FilesystemError> {
756        self.reader
757            .seek(SeekFrom::Start(self.partition_offset + self.mdb.map_offset))?;
758        let bytes = self.map_bytes.clone();
759        self.reader.write_all(&bytes)?;
760        Ok(())
761    }
762}
763// ---------------------------------------------------------------------------
764// fsck (check) — allocation-map-vs-directory reconciliation
765// ---------------------------------------------------------------------------
766
767/// Outcome of recomputing the allocated-block set from the directory. `used`
768/// is indexed by allocation-block number (valid entries live at 2..=num_alloc
769/// blocks + 1); `broken` lists structural faults (loop, out-of-range link,
770/// link into a free block, cross-link) that make the walk incomplete — while
771/// it is non-empty a map rewrite is unsafe.
772struct MfsWalk {
773    used: Vec<bool>,
774    files: u32,
775    broken: Vec<String>,
776}
777
778impl<R: Read + Seek + Send> MfsFilesystem<R> {
779    /// Highest addressable allocation block (blocks are numbered from 2).
780    fn last_block(&self) -> u16 {
781        self.mdb.num_alloc_blocks + 1
782    }
783
784    /// Walk one fork's allocation-block chain, marking every block it visits in
785    /// `used`. `owner` names the file for diagnostics. Structural faults are
786    /// pushed to `broken` (the walk of that fork stops at the fault).
787    fn walk_fork(
788        &self,
789        first: u16,
790        owner: &str,
791        fork: &str,
792        used: &mut [bool],
793        broken: &mut Vec<String>,
794    ) {
795        if first == 0 {
796            return;
797        }
798        let last = self.last_block();
799        let mut block = first;
800        for _ in 0..MAX_ALLOC_CHAIN {
801            if block < 2 || block > last {
802                broken.push(format!(
803                    "file '{owner}' {fork} fork links to block {block}, outside the volume (2..={last})"
804                ));
805                return;
806            }
807            if used[block as usize] {
808                broken.push(format!(
809                    "block {block} is cross-linked (claimed by '{owner}' {fork} fork and another chain)"
810                ));
811                return;
812            }
813            used[block as usize] = true;
814            let next = self.map_get(block);
815            if next == 1 {
816                return; // end-of-chain
817            }
818            if next == 0 {
819                broken.push(format!(
820                    "file '{owner}' {fork} fork chain hits a free block after {block}"
821                ));
822                return;
823            }
824            block = next;
825        }
826        broken.push(format!(
827            "file '{owner}' {fork} fork chain exceeds {MAX_ALLOC_CHAIN} blocks (runaway)"
828        ));
829    }
830
831    /// Recompute the allocated-block set from every in-use directory entry's
832    /// data + resource fork chain (the CBM/Atari VALIDATE model applied to the
833    /// MFS volume-allocation map).
834    fn compute_used(&self) -> MfsWalk {
835        let mut used = vec![false; self.last_block() as usize + 1];
836        let mut broken = Vec::new();
837        let mut files = 0u32;
838        let entries: Vec<MfsDirEntry> = self
839            .entries
840            .iter()
841            .filter(|e| e.is_in_use())
842            .cloned()
843            .collect();
844        for de in &entries {
845            files += 1;
846            self.walk_fork(
847                de.data_first_block,
848                &de.name,
849                "data",
850                &mut used,
851                &mut broken,
852            );
853            self.walk_fork(
854                de.rsrc_first_block,
855                &de.name,
856                "resource",
857                &mut used,
858                &mut broken,
859            );
860        }
861        MfsWalk {
862            used,
863            files,
864            broken,
865        }
866    }
867
868    /// Count allocation blocks currently marked free in the on-disk map.
869    fn map_free_count(&self) -> u32 {
870        (2..=self.last_block())
871            .filter(|&b| self.map_get(b) == 0)
872            .count() as u32
873    }
874
875    /// MFS integrity check: reconcile the volume allocation-block map, the free
876    /// count, and the file count against the directory walk. Repair (freeing
877    /// unreachable blocks + fixing the counters) is offered only when the walk
878    /// completed without structural damage.
879    fn run_fsck(&mut self) -> Result<FsckResult, FilesystemError> {
880        let walk = self.compute_used();
881        let walk_clean = walk.broken.is_empty();
882        let last = self.last_block();
883
884        // Leaked blocks: marked allocated (map entry non-zero) but unreachable
885        // from any file's fork chain.
886        let mut leaked = 0u32;
887        for b in 2..=last {
888            if self.map_get(b) != 0 && !walk.used[b as usize] {
889                leaked += 1;
890            }
891        }
892
893        let mut errors = Vec::new();
894        for msg in &walk.broken {
895            errors.push(FsckIssue {
896                code: "MfsBrokenChain".into(),
897                message: msg.clone(),
898                repairable: false,
899                debug: false,
900            });
901        }
902        if leaked > 0 {
903            errors.push(FsckIssue {
904                code: "MfsLostBlocks".into(),
905                message: format!(
906                    "{leaked} allocation block(s) marked used but unreachable from any file"
907                ),
908                repairable: walk_clean,
909                debug: false,
910            });
911        }
912
913        // Free-count reconciliation. The recomputed free count treats leaked
914        // blocks as free (they will be after a repair); the on-disk drFreeBks
915        // should match the *current* map, which counts them as allocated.
916        let map_free = self.map_free_count();
917        if self.mdb.free_blocks as u32 != map_free {
918            errors.push(FsckIssue {
919                code: "MfsFreeCountMismatch".into(),
920                message: format!(
921                    "MDB free-block count is {} but the allocation map has {map_free} free",
922                    self.mdb.free_blocks
923                ),
924                repairable: walk_clean,
925                debug: false,
926            });
927        }
928
929        // File-count reconciliation.
930        if self.mdb.num_files as u32 != walk.files {
931            errors.push(FsckIssue {
932                code: "MfsFileCountMismatch".into(),
933                message: format!(
934                    "MDB file count is {} but the directory holds {} file(s)",
935                    self.mdb.num_files, walk.files
936                ),
937                repairable: walk_clean,
938                debug: false,
939            });
940        }
941
942        let repairable = errors.iter().any(|e| e.repairable);
943        let free_after = map_free + leaked; // free once leaked blocks are reclaimed
944        Ok(FsckResult {
945            errors,
946            warnings: Vec::new(),
947            stats: FsckStats {
948                files_checked: walk.files,
949                directories_checked: 1,
950                extra: vec![
951                    (
952                        "free blocks".into(),
953                        format!("{free_after} / {}", self.mdb.num_alloc_blocks),
954                    ),
955                    (
956                        "allocation map".into(),
957                        if leaked == 0 && walk_clean {
958                            "consistent".into()
959                        } else {
960                            "needs rebuild".into()
961                        },
962                    ),
963                ],
964            },
965            repairable,
966            orphaned_entries: Vec::new(),
967        })
968    }
969}
970
971// ---------------------------------------------------------------------------
972// fsck (repair) — reclaim leaked blocks + resync the MDB counters
973// ---------------------------------------------------------------------------
974
975impl<R: Read + Write + Seek + Send> MfsFilesystem<R> {
976    /// Repair the volume allocation map + MDB counters. Leaked (allocated but
977    /// unreachable) blocks are freed; the free-block and file counts are reset
978    /// to match the directory. Withheld entirely when the walk found structural
979    /// damage — the MFS map encodes each file's chain linkage, so a broken
980    /// chain could hide live blocks and rewriting would risk data loss.
981    fn run_repair(&mut self) -> Result<RepairReport, FilesystemError> {
982        let walk = self.compute_used();
983        if !walk.broken.is_empty() {
984            return Ok(RepairReport {
985                fixes_applied: Vec::new(),
986                fixes_failed: vec![
987                    "structural damage (broken/cross-linked chain) — the allocation map was left \
988                     unchanged to avoid losing live blocks"
989                        .into(),
990                ],
991                unrepairable_count: walk.broken.len(),
992            });
993        }
994
995        let last = self.last_block();
996        let mut freed = 0u32;
997        for b in 2..=last {
998            if self.map_get(b) != 0 && !walk.used[b as usize] {
999                self.map_set(b, 0);
1000                freed += 1;
1001            }
1002        }
1003
1004        let map_free = self.map_free_count();
1005        let mut fixes = Vec::new();
1006        if freed > 0 {
1007            fixes.push(format!(
1008                "reclaimed {freed} unreachable allocation block(s) into the free map"
1009            ));
1010        }
1011        if self.mdb.free_blocks as u32 != map_free {
1012            fixes.push(format!("corrected the MDB free-block count to {map_free}"));
1013            self.mdb.free_blocks = map_free as u16;
1014        }
1015        if self.mdb.num_files as u32 != walk.files {
1016            fixes.push(format!("corrected the MDB file count to {}", walk.files));
1017            self.mdb.num_files = walk.files as u16;
1018        }
1019
1020        if fixes.is_empty() {
1021            return Ok(RepairReport {
1022                fixes_applied: Vec::new(),
1023                fixes_failed: Vec::new(),
1024                unrepairable_count: 0,
1025            });
1026        }
1027
1028        self.map_write_back()?;
1029        self.mdb_write_back()?;
1030        self.reader.flush()?;
1031        Ok(RepairReport {
1032            fixes_applied: fixes,
1033            fixes_failed: Vec::new(),
1034            unrepairable_count: 0,
1035        })
1036    }
1037}
1038
1039impl<R: Read + Seek + Send> Filesystem for MfsFilesystem<R> {
1040    fn root(&mut self) -> Result<FileEntry, FilesystemError> {
1041        Ok(FileEntry::new_directory("/".into(), "/".into(), 0))
1042    }
1043
1044    fn list_directory(&mut self, entry: &FileEntry) -> Result<Vec<FileEntry>, FilesystemError> {
1045        if entry.path != "/" {
1046            // MFS is a flat filesystem — only the root has children.
1047            return Ok(Vec::new());
1048        }
1049        let mut out = Vec::with_capacity(self.entries.len());
1050        for de in &self.entries {
1051            if !de.is_in_use() {
1052                continue;
1053            }
1054            let path = format!("/{}", de.name);
1055            let mut fe = FileEntry::new_file(
1056                de.name.clone(),
1057                path,
1058                de.data_logical_length as u64,
1059                de.file_number as u64,
1060            );
1061            fe.type_code = Some(de.finder_info[0..4].try_into().unwrap());
1062            fe.creator_code = Some(de.finder_info[4..8].try_into().unwrap());
1063            fe.finder_flags = Some(de.finder_flags());
1064            if de.rsrc_logical_length > 0 {
1065                fe.resource_fork_size = Some(de.rsrc_logical_length as u64);
1066            }
1067            // MFS dates share HFS's Mac-epoch encoding.
1068            fe.modified_unix = disk_core::times::mac_local_to_unix(de.modify_date);
1069            if de.modify_date != 0 || de.create_date != 0 {
1070                fe.mac_dates = Some((de.create_date, de.modify_date, 0));
1071                if let Some(s) = crate::hfs_common::format_mac_date(de.modify_date) {
1072                    fe.modified = Some(s);
1073                }
1074            }
1075            out.push(fe);
1076        }
1077        out.sort_by_key(|a| a.name.to_lowercase());
1078        Ok(out)
1079    }
1080
1081    fn read_file(
1082        &mut self,
1083        entry: &FileEntry,
1084        max_bytes: usize,
1085    ) -> Result<Vec<u8>, FilesystemError> {
1086        let fnum = entry.location as u32;
1087        let de = self
1088            .entry_by_file_number(fnum)
1089            .ok_or_else(|| FilesystemError::NotFound(format!("file number {fnum} not found")))?
1090            .clone();
1091        let mut data = self.read_chain(de.data_first_block, de.data_logical_length)?;
1092        data.truncate(max_bytes);
1093        Ok(data)
1094    }
1095
1096    fn validate_name(&self, name: &str) -> Result<(), FilesystemError> {
1097        validate_mfs_name(name).map(|_| ())
1098    }
1099    fn fs_type(&self) -> &str {
1100        "MFS"
1101    }
1102
1103    fn volume_label(&self) -> Option<&str> {
1104        if self.mdb.volume_name.is_empty() {
1105            None
1106        } else {
1107            Some(&self.mdb.volume_name)
1108        }
1109    }
1110
1111    fn total_size(&self) -> u64 {
1112        self.mdb.num_alloc_blocks as u64 * self.mdb.alloc_block_size as u64
1113    }
1114
1115    fn used_size(&self) -> u64 {
1116        (self.mdb.num_alloc_blocks - self.mdb.free_blocks) as u64 * self.mdb.alloc_block_size as u64
1117    }
1118
1119    /// MFS allocates fork storage in fixed-size allocation blocks
1120    /// (`drAlBlkSiz`), so a fork's real on-disk footprint is its length
1121    /// rounded up to this unit. Exposing it lets the copy preflight and
1122    /// `du` model destination consumption exactly instead of falling back
1123    /// to a 512-byte floor.
1124    fn allocation_unit(&self) -> Option<u64> {
1125        if self.mdb.alloc_block_size == 0 {
1126            None
1127        } else {
1128            Some(self.mdb.alloc_block_size as u64)
1129        }
1130    }
1131
1132    /// Stream a file's resource fork. MFS files carry a data + resource fork
1133    /// like HFS; consumers (`get-binhex`, MacBinary / AppleDouble export, `cp`,
1134    /// the remote daemon) reach it only through this trait method, so it must
1135    /// bridge to the inherent [`read_resource_fork`](Self::read_resource_fork)
1136    /// (which follows the resource fork's own allocation-block chain).
1137    fn write_resource_fork_to(
1138        &mut self,
1139        entry: &FileEntry,
1140        writer: &mut dyn std::io::Write,
1141    ) -> Result<u64, FilesystemError> {
1142        let data = self.read_resource_fork(entry)?;
1143        writer.write_all(&data)?;
1144        Ok(data.len() as u64)
1145    }
1146
1147    fn resource_fork_size(&mut self, entry: &FileEntry) -> u64 {
1148        self.entry_by_file_number(entry.location as u32)
1149            .map(|de| de.rsrc_logical_length as u64)
1150            .unwrap_or(0)
1151    }
1152
1153    fn fsck(&mut self) -> Option<Result<FsckResult, FilesystemError>> {
1154        Some(self.run_fsck())
1155    }
1156}
1157
1158impl<R: Read + Write + Seek + Send> EditableFilesystem for MfsFilesystem<R> {
1159    fn as_filesystem(&self) -> &dyn disk_core::filesystem::Filesystem {
1160        self
1161    }
1162    fn as_filesystem_mut(&mut self) -> &mut dyn disk_core::filesystem::Filesystem {
1163        self
1164    }
1165    fn create_file(
1166        &mut self,
1167        parent: &FileEntry,
1168        name: &str,
1169        data: &mut dyn std::io::Read,
1170        data_len: u64,
1171        options: &CreateFileOptions,
1172    ) -> Result<FileEntry, FilesystemError> {
1173        if parent.path != "/" {
1174            return Err(FilesystemError::Unsupported(
1175                "MFS is flat; only the root directory accepts new files".into(),
1176            ));
1177        }
1178        let _ = validate_mfs_name(name)?;
1179        // Reject duplicates; the File Manager compares names without case.
1180        if self
1181            .entries
1182            .iter()
1183            .any(|e| e.is_in_use() && mfs_names_equal(&e.name, name))
1184        {
1185            return Err(FilesystemError::InvalidData(format!(
1186                "MFS file '{name}' already exists"
1187            )));
1188        }
1189        if data_len > u32::MAX as u64 {
1190            return Err(FilesystemError::InvalidData(
1191                "MFS file size capped at u32::MAX bytes".into(),
1192            ));
1193        }
1194        // Read all data into memory. MFS volumes top out at 800 KB so
1195        // this is fine.
1196        let mut bytes = Vec::with_capacity(data_len as usize);
1197        data.read_to_end(&mut bytes)?;
1198        if bytes.len() as u64 != data_len {
1199            return Err(FilesystemError::InvalidData(format!(
1200                "data_len {data_len} != actual {} bytes",
1201                bytes.len()
1202            )));
1203        }
1204        let (first_block, _) = self.alloc_and_write_fork(&bytes)?;
1205
1206        // Resource fork from CreateFileOptions, if the caller supplied one
1207        // (put-binhex / put-macbinary / fork-carrying cp). Roll the data
1208        // allocation back if the resource fork can't fit, so a failed create
1209        // leaks nothing.
1210        let rsrc_bytes: Vec<u8> = match &options.resource_fork {
1211            Some(disk_core::filesystem::ResourceForkSource::Data(d)) => d.clone(),
1212            Some(disk_core::filesystem::ResourceForkSource::File(p)) => {
1213                std::fs::read(p).map_err(FilesystemError::Io)?
1214            }
1215            None => Vec::new(),
1216        };
1217        let (rsrc_first_block, rsrc_logical_length) = match self.alloc_and_write_fork(&rsrc_bytes) {
1218            Ok(v) => v,
1219            Err(e) => {
1220                let _ = self.free_chain(first_block);
1221                return Err(e);
1222            }
1223        };
1224
1225        let file_number = self.mdb.next_file_number;
1226        self.mdb.next_file_number += 1;
1227        self.mdb.num_files += 1;
1228
1229        // Honor the caller's Mac type/creator, filling any missing or blank half
1230        // from the extension dictionary. Written into the dir entry's Finder
1231        // info so the codes actually persist on disk (and the returned entry
1232        // matches).
1233        let (type_code, creator_code) = crate::hfs_common::resolve_create_type_creator(
1234            name,
1235            options.os_type,
1236            options.os_creator,
1237            options.type_code.as_deref(),
1238            options.creator_code.as_deref(),
1239        );
1240        let mut finder_info = [0u8; 16];
1241        finder_info[0..4].copy_from_slice(&type_code);
1242        finder_info[4..8].copy_from_slice(&creator_code);
1243
1244        // Cross-fs import passes the source mtime through options.unix_times;
1245        // a genuinely new file leaves it None and we stamp `now`. MFS's dir
1246        // entry carries create + modify dates in Mac-epoch seconds — the same
1247        // encoding HFS/HFS+ use.
1248        let mtime_secs = options
1249            .unix_times
1250            .map(|t| t.mtime_or_now())
1251            .unwrap_or_else(disk_core::times::now);
1252        let stamp = disk_core::times::unix_to_mac_local(mtime_secs);
1253        let entry = MfsDirEntry {
1254            flags: 0x80, // in use, not locked
1255            finder_info,
1256            file_number,
1257            data_first_block: first_block,
1258            data_logical_length: bytes.len() as u32,
1259            rsrc_first_block,
1260            rsrc_logical_length,
1261            create_date: stamp,
1262            modify_date: stamp,
1263            name: name.to_string(),
1264        };
1265        self.entries.push(entry);
1266        // Surface a freshly-built FileEntry for the caller, reflecting the
1267        // type/creator actually written.
1268        let mut fe = FileEntry::new_file(
1269            name.to_string(),
1270            format!("/{name}"),
1271            bytes.len() as u64,
1272            file_number as u64,
1273        );
1274        fe.type_code = Some(type_code);
1275        fe.creator_code = Some(creator_code);
1276        if rsrc_logical_length > 0 {
1277            fe.resource_fork_size = Some(rsrc_logical_length as u64);
1278        }
1279        fe.modified_unix = Some(mtime_secs);
1280        fe.mac_dates = Some((stamp, stamp, 0));
1281        Ok(fe)
1282    }
1283
1284    fn create_directory(
1285        &mut self,
1286        _parent: &FileEntry,
1287        _name: &str,
1288        _options: &CreateDirectoryOptions,
1289    ) -> Result<FileEntry, FilesystemError> {
1290        Err(FilesystemError::Unsupported(
1291            "MFS has no directory hierarchy — all files live in the volume root".into(),
1292        ))
1293    }
1294
1295    fn delete_entry(
1296        &mut self,
1297        parent: &FileEntry,
1298        entry: &FileEntry,
1299    ) -> Result<(), FilesystemError> {
1300        if parent.path != "/" {
1301            return Err(FilesystemError::Unsupported(
1302                "MFS is flat; only files in the root are deletable".into(),
1303            ));
1304        }
1305        if entry.entry_type != EntryType::File {
1306            return Err(FilesystemError::InvalidData(
1307                "MFS only stores files; cannot delete a directory".into(),
1308            ));
1309        }
1310        let fnum = entry.location as u32;
1311        // Snapshot the chain heads before we touch the entries vec.
1312        let (data_head, rsrc_head) = {
1313            let de = self
1314                .entries
1315                .iter()
1316                .find(|e| e.is_in_use() && e.file_number == fnum)
1317                .ok_or_else(|| {
1318                    FilesystemError::NotFound(format!("file number {fnum} not in directory"))
1319                })?;
1320            (de.data_first_block, de.rsrc_first_block)
1321        };
1322        self.free_chain(data_head)?;
1323        self.free_chain(rsrc_head)?;
1324        // Remove the entry from the in-memory directory.
1325        self.entries
1326            .retain(|e| !(e.is_in_use() && e.file_number == fnum));
1327        self.mdb.num_files = self.mdb.num_files.saturating_sub(1);
1328        Ok(())
1329    }
1330
1331    fn sync_metadata(&mut self) -> Result<(), FilesystemError> {
1332        self.dir_write_back()?;
1333        self.map_write_back()?;
1334        self.mdb_write_back()?;
1335        self.reader.flush()?;
1336        Ok(())
1337    }
1338
1339    fn free_space(&mut self) -> Result<u64, FilesystemError> {
1340        Ok(self.mdb.free_blocks as u64 * self.mdb.alloc_block_size as u64)
1341    }
1342
1343    fn repair(&mut self) -> Result<RepairReport, FilesystemError> {
1344        self.run_repair()
1345    }
1346
1347    /// Replace a file's resource fork (used by `setrsrc`, the batch archive
1348    /// importer, and fork-carrying `cp`). Frees the old resource chain, then
1349    /// allocates + writes the new one and updates the directory entry. Call
1350    /// `sync_metadata` to persist. Freeing before allocating means a replacement
1351    /// only needs the *delta* in free space, not room for both copies.
1352    fn write_resource_fork(
1353        &mut self,
1354        entry: &FileEntry,
1355        data: &mut dyn std::io::Read,
1356        len: u64,
1357    ) -> Result<(), FilesystemError> {
1358        if len > u32::MAX as u64 {
1359            return Err(FilesystemError::InvalidData(
1360                "MFS resource fork capped at u32::MAX bytes".into(),
1361            ));
1362        }
1363        let fnum = entry.location as u32;
1364        let old_head = self
1365            .entries
1366            .iter()
1367            .find(|e| e.is_in_use() && e.file_number == fnum)
1368            .map(|e| e.rsrc_first_block)
1369            .ok_or_else(|| {
1370                FilesystemError::NotFound(format!("file number {fnum} not in directory"))
1371            })?;
1372        let mut bytes = Vec::with_capacity(len as usize);
1373        data.read_to_end(&mut bytes)?;
1374        self.free_chain(old_head)?;
1375        let (first, rlen) = self.alloc_and_write_fork(&bytes)?;
1376        let de = self
1377            .entries
1378            .iter_mut()
1379            .find(|e| e.is_in_use() && e.file_number == fnum)
1380            .expect("entry present after lookup above");
1381        de.rsrc_first_block = first;
1382        de.rsrc_logical_length = rlen;
1383        Ok(())
1384    }
1385
1386    fn set_type_creator(
1387        &mut self,
1388        entry: &FileEntry,
1389        type_code: &str,
1390        creator_code: &str,
1391    ) -> Result<(), FilesystemError> {
1392        if type_code.len() != 4 || creator_code.len() != 4 {
1393            return Err(FilesystemError::InvalidData(
1394                "Mac OSType / creator must be exactly 4 chars".into(),
1395            ));
1396        }
1397        let fnum = entry.location as u32;
1398        let de = self
1399            .entries
1400            .iter_mut()
1401            .find(|e| e.is_in_use() && e.file_number == fnum)
1402            .ok_or_else(|| {
1403                FilesystemError::NotFound(format!("file number {fnum} not in directory"))
1404            })?;
1405        de.finder_info[0..4].copy_from_slice(type_code.as_bytes());
1406        de.finder_info[4..8].copy_from_slice(creator_code.as_bytes());
1407        Ok(())
1408    }
1409
1410    fn rename(
1411        &mut self,
1412        parent: &FileEntry,
1413        entry: &FileEntry,
1414        new_name: &str,
1415    ) -> Result<(), FilesystemError> {
1416        if new_name == entry.name {
1417            return Ok(());
1418        }
1419        if parent.path != "/" {
1420            return Err(FilesystemError::Unsupported(
1421                "MFS is flat; only files in the root are renamable".into(),
1422            ));
1423        }
1424        if entry.entry_type != EntryType::File {
1425            return Err(FilesystemError::InvalidData(
1426                "MFS only stores files; cannot rename a directory".into(),
1427            ));
1428        }
1429        let _ = validate_mfs_name(new_name)?;
1430
1431        let fnum = entry.location as u32;
1432        // Reject a collision with a *different* in-use entry; the File Manager
1433        // compares names without case, so "Doc" and "DOC" are one file.
1434        if self
1435            .entries
1436            .iter()
1437            .any(|e| e.is_in_use() && e.file_number != fnum && mfs_names_equal(&e.name, new_name))
1438        {
1439            return Err(FilesystemError::AlreadyExists(new_name.to_string()));
1440        }
1441
1442        // Overwrite only the name; flags, Finder info, file number, fork heads,
1443        // lengths and dates are untouched, so the file keeps its identity and
1444        // contents. Persistence happens via sync_metadata -> dir_write_back.
1445        let de = self
1446            .entries
1447            .iter_mut()
1448            .find(|e| e.is_in_use() && e.file_number == fnum)
1449            .ok_or_else(|| {
1450                FilesystemError::NotFound(format!("file number {fnum} not in directory"))
1451            })?;
1452        de.name = new_name.to_string();
1453        Ok(())
1454    }
1455}
1456
1457// ---------------------------------------------------------------------------
1458// create-blank formatter
1459// ---------------------------------------------------------------------------
1460
1461/// Geometry of a freshly formatted MFS volume, in 512-byte logical sectors.
1462struct MfsBlankGeom {
1463    total_sectors: u64,
1464    alloc_block_size: u32,
1465    num_alloc_blocks: u16,
1466    dir_start_sector: u16,
1467    dir_length_sectors: u16,
1468    first_alloc_block_sector: u16,
1469}
1470
1471/// Directory length (in 512-byte sectors) for a blank volume. 12 sectors — the
1472/// canonical 400 KB MFS directory — is the floor; larger volumes get more so a
1473/// full floppy's file count isn't artificially capped. The on-disk `drDirLen`
1474/// field is a u16 count of sectors, so 255 is a comfortable ceiling.
1475fn blank_dir_sectors(num_alloc_blocks: u32) -> u64 {
1476    (num_alloc_blocks as u64 / 40).clamp(12, 255)
1477}
1478
1479/// Solve the reserved-region layout for a blank MFS volume of `size_bytes`.
1480///
1481/// MFS packs one 12-bit allocation-map entry per block, so at most 4094 blocks
1482/// (0/1 reserved) are addressable; the allocation-block size is grown from
1483/// 1 KiB in 512-byte steps until the data region fits under that ceiling. The
1484/// reserved region is boot(2) + MDB/map + directory, solved to a fixed point
1485/// because the map length depends on the block count, which depends on where
1486/// the data region starts.
1487fn mfs_blank_geometry(size_bytes: u64) -> Result<MfsBlankGeom, FilesystemError> {
1488    const SECTOR: u64 = 512;
1489    if size_bytes < 32 * SECTOR {
1490        return Err(FilesystemError::InvalidData(
1491            "MFS volume must be at least 16 KiB".into(),
1492        ));
1493    }
1494    let total_sectors = size_bytes / SECTOR;
1495
1496    // Grow the block size (in sectors-per-block) until the block count fits the
1497    // 12-bit map. spb 128 = 64 KiB blocks → ~256 MB, well past any real MFS.
1498    for spb in 2..=128u64 {
1499        let abs = spb * SECTOR;
1500        let mut first_ab: u64 = 16; // seed with the canonical 400 KB value
1501        let mut num_ab = 0u64;
1502        let mut dir_start = 4u64;
1503        let mut dir_len = 12u64;
1504        let mut converged = false;
1505        for _ in 0..16 {
1506            if total_sectors <= first_ab {
1507                break;
1508            }
1509            num_ab = (total_sectors - first_ab) / spb;
1510            let map_bytes = (num_ab * 12).div_ceil(8);
1511            // The MDB sits at sector 2; its allocation map begins 64 bytes in
1512            // and may straddle into following sectors.
1513            let mdb_map_sectors = (64 + map_bytes).div_ceil(SECTOR);
1514            dir_start = 2 + mdb_map_sectors;
1515            dir_len = blank_dir_sectors(num_ab as u32);
1516            let next_first = dir_start + dir_len;
1517            if next_first == first_ab {
1518                converged = true;
1519                break;
1520            }
1521            first_ab = next_first;
1522        }
1523        if !converged || num_ab == 0 {
1524            continue;
1525        }
1526        if num_ab > 4094 {
1527            continue; // too many blocks for the 12-bit map at this size — grow abs
1528        }
1529        return Ok(MfsBlankGeom {
1530            total_sectors,
1531            alloc_block_size: abs as u32,
1532            num_alloc_blocks: num_ab as u16,
1533            dir_start_sector: dir_start as u16,
1534            dir_length_sectors: dir_len as u16,
1535            first_alloc_block_sector: first_ab as u16,
1536        });
1537    }
1538    Err(FilesystemError::InvalidData(
1539        "MFS volume too large to format (max ~256 MB)".into(),
1540    ))
1541}
1542
1543/// Format a blank MFS volume of `size_bytes` with volume name `name`.
1544///
1545/// Produces a bare MFS image: zeroed boot blocks (non-bootable data volume),
1546/// an MDB describing the geometry, an all-free allocation map, and an empty
1547/// file directory. The canonical 400 KB / 800 KB floppy sizes format cleanly,
1548/// as do larger sizes up to the 12-bit-map ceiling. The result round-trips
1549/// through [`MfsFilesystem::open`] and accepts files via `EditableFilesystem`.
1550pub fn create_blank_mfs(size_bytes: u64, name: &str) -> Result<Vec<u8>, FilesystemError> {
1551    let g = mfs_blank_geometry(size_bytes)?;
1552    let total_bytes = g.total_sectors * 512;
1553    let mut disk = vec![0u8; total_bytes as usize];
1554
1555    // Clump size: 8 allocation blocks, capped at the data region so it stays
1556    // meaningful on tiny volumes. Cosmetic (the allocator works in blocks), but
1557    // real disks carry a sensible value here.
1558    let data_bytes = g.num_alloc_blocks as u32 * g.alloc_block_size;
1559    let clump = (g.alloc_block_size.saturating_mul(8)).min(data_bytes.max(g.alloc_block_size));
1560
1561    let mdb = &mut disk[MDB_OFFSET as usize..MDB_OFFSET as usize + 512];
1562    BigEndian::write_u16(&mut mdb[0..2], MFS_SIGNATURE);
1563    // Create / last-backup dates left 0 (Mac epoch); real Mac OS restamps them.
1564    BigEndian::write_u16(&mut mdb[10..12], 0); // attributes
1565    BigEndian::write_u16(&mut mdb[12..14], 0); // num files
1566    BigEndian::write_u16(&mut mdb[14..16], g.dir_start_sector);
1567    BigEndian::write_u16(&mut mdb[16..18], g.dir_length_sectors);
1568    BigEndian::write_u16(&mut mdb[18..20], g.num_alloc_blocks);
1569    BigEndian::write_u32(&mut mdb[20..24], g.alloc_block_size);
1570    BigEndian::write_u32(&mut mdb[24..28], clump);
1571    BigEndian::write_u16(&mut mdb[28..30], g.first_alloc_block_sector);
1572    BigEndian::write_u32(&mut mdb[30..34], 1); // next file number
1573    BigEndian::write_u16(&mut mdb[34..36], g.num_alloc_blocks); // all free
1574
1575    let name_bytes = crate::hfs::utf8_to_mac_roman(name).unwrap_or_default();
1576    let name_len = name_bytes.len().min(MFS_MAX_NAME_LEN);
1577    mdb[36] = name_len as u8;
1578    mdb[37..37 + name_len].copy_from_slice(&name_bytes[..name_len]);
1579
1580    // The allocation map (byte 64 of the MDB, straddling into later sectors)
1581    // and the directory sectors are all-free / all-zero, which the surrounding
1582    // zero-fill already provides.
1583    Ok(disk)
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589    use std::io::Cursor;
1590
1591    /// Build a minimal valid MFS volume in memory. Two files:
1592    ///   - "Hello"   data fork = b"hello mfs"      (no resource fork)
1593    ///   - "Doc"     data fork = b"first line\n"   resource fork = b"RSRC"
1594    ///
1595    /// Layout (all 512 B sectors):
1596    ///   sector 0..1     boot blocks (zero)
1597    ///   sector 2        MDB + volume name + map
1598    ///   sector 3..4     file directory (2 sectors)
1599    ///   sector 5+       allocation blocks (1 KiB each — 2 sectors per block)
1600    ///
1601    /// Allocation block size = 1024 B (2 sectors). 8 alloc blocks total →
1602    /// 8 KiB volume of file data. Plenty for the test files.
1603    fn build_test_mfs() -> Vec<u8> {
1604        const SECTOR: usize = 512;
1605        const NUM_SECTORS: usize = 32; // 16 KiB volume
1606        const ALLOC_BLOCK_SIZE: u32 = 1024;
1607        const FIRST_ALLOC_BLOCK_SECTOR: u16 = 5;
1608        const DIR_START: u16 = 3;
1609        const DIR_LEN: u16 = 2;
1610        const NUM_ALLOC_BLOCKS: u16 = 8;
1611
1612        let mut disk = vec![0u8; NUM_SECTORS * SECTOR];
1613
1614        // --- MDB ---
1615        let mdb = &mut disk[1024..1024 + 512];
1616        BigEndian::write_u16(&mut mdb[0..2], MFS_SIGNATURE);
1617        BigEndian::write_u32(&mut mdb[2..6], 0); // create date
1618        BigEndian::write_u32(&mut mdb[6..10], 0); // last backup
1619        BigEndian::write_u16(&mut mdb[10..12], 0); // attributes
1620        BigEndian::write_u16(&mut mdb[12..14], 2); // num files
1621        BigEndian::write_u16(&mut mdb[14..16], DIR_START);
1622        BigEndian::write_u16(&mut mdb[16..18], DIR_LEN);
1623        BigEndian::write_u16(&mut mdb[18..20], NUM_ALLOC_BLOCKS);
1624        BigEndian::write_u32(&mut mdb[20..24], ALLOC_BLOCK_SIZE);
1625        BigEndian::write_u32(&mut mdb[24..28], ALLOC_BLOCK_SIZE);
1626        BigEndian::write_u16(&mut mdb[28..30], FIRST_ALLOC_BLOCK_SECTOR);
1627        BigEndian::write_u32(&mut mdb[30..34], 3); // next file number
1628        BigEndian::write_u16(&mut mdb[34..36], NUM_ALLOC_BLOCKS - 3); // free blocks
1629        let vname = b"Tester";
1630        mdb[36] = vname.len() as u8;
1631        mdb[37..37 + vname.len()].copy_from_slice(vname);
1632
1633        // --- Volume map ---
1634        // Map starts at the fixed offset 1024 + 64 = 1088. 8 alloc blocks × 12
1635        // bits = 96 bits = 12 bytes. Blocks 0 & 1 reserved; entry index 0 is
1636        // block 2. Layout:
1637        //   Block 2 → 1 (end of chain for "Hello")
1638        //   Block 3 → 1 (end of chain for "Doc" data fork)
1639        //   Block 4 → 1 (end of chain for "Doc" rsrc fork)
1640        //   Blocks 5..9 → 0 (free)
1641        let map_start = 1024 + 64;
1642        let mut map = vec![0u8; 12];
1643        write_map_entry(&mut map, 2, 1);
1644        write_map_entry(&mut map, 3, 1);
1645        write_map_entry(&mut map, 4, 1);
1646        disk[map_start..map_start + map.len()].copy_from_slice(&map);
1647
1648        // --- File directory ---
1649        // Build two entries in sector 3 (first dir sector).
1650        let mut dir_sec = vec![0u8; SECTOR];
1651        let off = encode_dir_entry(
1652            &mut dir_sec,
1653            0,
1654            DirEntryIn {
1655                flags: 0x80,
1656                file_number: 1,
1657                data_first_block: 2,
1658                data_logical_length: 9, // "hello mfs"
1659                data_physical_length: ALLOC_BLOCK_SIZE,
1660                rsrc_first_block: 0,
1661                rsrc_logical_length: 0,
1662                rsrc_physical_length: 0,
1663                type_code: *b"TEXT",
1664                creator_code: *b"ttxt",
1665                name: b"Hello",
1666            },
1667        );
1668        let off = encode_dir_entry(
1669            &mut dir_sec,
1670            off,
1671            DirEntryIn {
1672                flags: 0x80,
1673                file_number: 2,
1674                data_first_block: 3,
1675                data_logical_length: 11, // "first line\n"
1676                data_physical_length: ALLOC_BLOCK_SIZE,
1677                rsrc_first_block: 4,
1678                rsrc_logical_length: 4, // "RSRC"
1679                rsrc_physical_length: ALLOC_BLOCK_SIZE,
1680                type_code: *b"TEXT",
1681                creator_code: *b"ttxt",
1682                name: b"Doc",
1683            },
1684        );
1685        let _ = off;
1686        disk[3 * SECTOR..3 * SECTOR + SECTOR].copy_from_slice(&dir_sec);
1687
1688        // --- File data ---
1689        let alloc_start = FIRST_ALLOC_BLOCK_SECTOR as usize * SECTOR;
1690        // Block 2 — "Hello"'s data
1691        disk[alloc_start..alloc_start + 9].copy_from_slice(b"hello mfs");
1692        // Block 3 — "Doc"'s data (1 alloc block later)
1693        let b3 = alloc_start + ALLOC_BLOCK_SIZE as usize;
1694        disk[b3..b3 + 11].copy_from_slice(b"first line\n");
1695        // Block 4 — "Doc"'s rsrc
1696        let b4 = alloc_start + 2 * ALLOC_BLOCK_SIZE as usize;
1697        disk[b4..b4 + 4].copy_from_slice(b"RSRC");
1698
1699        disk
1700    }
1701
1702    struct DirEntryIn<'a> {
1703        flags: u8,
1704        file_number: u32,
1705        data_first_block: u16,
1706        data_logical_length: u32,
1707        data_physical_length: u32,
1708        rsrc_first_block: u16,
1709        rsrc_logical_length: u32,
1710        rsrc_physical_length: u32,
1711        type_code: [u8; 4],
1712        creator_code: [u8; 4],
1713        name: &'a [u8],
1714    }
1715
1716    fn encode_dir_entry(buf: &mut [u8], off: usize, e: DirEntryIn) -> usize {
1717        buf[off] = e.flags;
1718        buf[off + 1] = 0; // version
1719        buf[off + 2..off + 6].copy_from_slice(&e.type_code);
1720        buf[off + 6..off + 10].copy_from_slice(&e.creator_code);
1721        // finder_info bytes 8..16 zeroed.
1722        BigEndian::write_u32(&mut buf[off + 18..off + 22], e.file_number);
1723        BigEndian::write_u16(&mut buf[off + 22..off + 24], e.data_first_block);
1724        BigEndian::write_u32(&mut buf[off + 24..off + 28], e.data_logical_length);
1725        BigEndian::write_u32(&mut buf[off + 28..off + 32], e.data_physical_length);
1726        BigEndian::write_u16(&mut buf[off + 32..off + 34], e.rsrc_first_block);
1727        BigEndian::write_u32(&mut buf[off + 34..off + 38], e.rsrc_logical_length);
1728        BigEndian::write_u32(&mut buf[off + 38..off + 42], e.rsrc_physical_length);
1729        // dates at +42 / +46 — leave zero.
1730        buf[off + 50] = e.name.len() as u8;
1731        buf[off + 51..off + 51 + e.name.len()].copy_from_slice(e.name);
1732        let entry_len = 51 + e.name.len();
1733        off + entry_len + (entry_len & 1)
1734    }
1735
1736    /// Write a 12-bit volume-map entry into the packed map buffer.
1737    /// Mirrors the high-nibble-first packing used by `map_get`.
1738    fn write_map_entry(map: &mut [u8], block: u16, value: u16) {
1739        let value = value & 0x0FFF;
1740        let bit_off = (block as usize - 2) * 12;
1741        let byte_off = bit_off / 8;
1742        if block.is_multiple_of(2) {
1743            map[byte_off] = (value >> 4) as u8;
1744            map[byte_off + 1] = (map[byte_off + 1] & 0x0F) | (((value & 0x0F) as u8) << 4);
1745        } else {
1746            map[byte_off] = (map[byte_off] & 0xF0) | ((value >> 8) as u8 & 0x0F);
1747            map[byte_off + 1] = (value & 0xFF) as u8;
1748        }
1749    }
1750
1751    #[test]
1752    fn parses_mdb_from_synthetic_volume() {
1753        let disk = build_test_mfs();
1754        let cur = Cursor::new(disk);
1755        let fs = MfsFilesystem::open(cur, 0).unwrap();
1756        assert_eq!(fs.mdb.num_files, 2);
1757        assert_eq!(fs.mdb.alloc_block_size, 1024);
1758        assert_eq!(fs.mdb.num_alloc_blocks, 8);
1759        assert_eq!(fs.mdb.first_alloc_block_sector, 5);
1760        assert_eq!(fs.volume_label(), Some("Tester"));
1761        assert_eq!(fs.fs_type(), "MFS");
1762    }
1763
1764    #[test]
1765    fn lists_root_directory() {
1766        let disk = build_test_mfs();
1767        let cur = Cursor::new(disk);
1768        let mut fs = MfsFilesystem::open(cur, 0).unwrap();
1769        let root = fs.root().unwrap();
1770        let entries = fs.list_directory(&root).unwrap();
1771        assert_eq!(entries.len(), 2);
1772        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
1773        assert!(names.contains(&"Doc"));
1774        assert!(names.contains(&"Hello"));
1775        let doc = entries.iter().find(|e| e.name == "Doc").unwrap();
1776        assert_eq!(doc.resource_fork_size, Some(4));
1777        let hello = entries.iter().find(|e| e.name == "Hello").unwrap();
1778        assert_eq!(hello.resource_fork_size, None);
1779        assert_eq!(hello.type_code, Some(*b"TEXT"));
1780        assert_eq!(hello.creator_code, Some(*b"ttxt"));
1781    }
1782
1783    #[test]
1784    fn reads_data_fork() {
1785        let disk = build_test_mfs();
1786        let cur = Cursor::new(disk);
1787        let mut fs = MfsFilesystem::open(cur, 0).unwrap();
1788        let root = fs.root().unwrap();
1789        let entries = fs.list_directory(&root).unwrap();
1790        let hello = entries.iter().find(|e| e.name == "Hello").unwrap();
1791        let data = fs.read_file(hello, 1024).unwrap();
1792        assert_eq!(&data, b"hello mfs");
1793        let doc = entries.iter().find(|e| e.name == "Doc").unwrap();
1794        let data = fs.read_file(doc, 1024).unwrap();
1795        assert_eq!(&data, b"first line\n");
1796    }
1797
1798    #[test]
1799    fn reads_resource_fork() {
1800        let disk = build_test_mfs();
1801        let cur = Cursor::new(disk);
1802        let mut fs = MfsFilesystem::open(cur, 0).unwrap();
1803        let root = fs.root().unwrap();
1804        let entries = fs.list_directory(&root).unwrap();
1805        let doc = entries.iter().find(|e| e.name == "Doc").unwrap();
1806        let rsrc = fs.read_resource_fork(doc).unwrap();
1807        assert_eq!(&rsrc, b"RSRC");
1808        let hello = entries.iter().find(|e| e.name == "Hello").unwrap();
1809        let empty = fs.read_resource_fork(hello).unwrap();
1810        assert!(empty.is_empty());
1811    }
1812
1813    #[test]
1814    fn resource_fork_via_trait_object() {
1815        // Regression: every real consumer (get-binhex, MacBinary/AppleDouble
1816        // export, cp, the remote daemon) holds a `dyn Filesystem` and reaches
1817        // the resource fork ONLY through the trait's `write_resource_fork_to` /
1818        // `resource_fork_size` — never the inherent `read_resource_fork`. Before
1819        // these were wired they defaulted to `Ok(0)` / `0`, silently dropping
1820        // every MFS resource fork.
1821        let disk = build_test_mfs();
1822        let mut fs = MfsFilesystem::open(Cursor::new(disk), 0).unwrap();
1823        let root = fs.root().unwrap();
1824        let entries = fs.list_directory(&root).unwrap();
1825        let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
1826        let hello = entries.iter().find(|e| e.name == "Hello").unwrap().clone();
1827
1828        let fsys: &mut dyn Filesystem = &mut fs;
1829        assert_eq!(fsys.resource_fork_size(&doc), 4);
1830        let mut buf = Vec::new();
1831        assert_eq!(fsys.write_resource_fork_to(&doc, &mut buf).unwrap(), 4);
1832        assert_eq!(&buf, b"RSRC");
1833
1834        // A fork-less file still yields zero through the trait.
1835        assert_eq!(fsys.resource_fork_size(&hello), 0);
1836        let mut empty = Vec::new();
1837        assert_eq!(fsys.write_resource_fork_to(&hello, &mut empty).unwrap(), 0);
1838        assert!(empty.is_empty());
1839    }
1840
1841    #[test]
1842    fn resource_fork_survives_real_export_consumer() {
1843        // End-to-end through the *actual* shared export path used by the browse
1844        // view's "Extract" and Commander's copy-to-host
1845        // (fork_export::export_file_with_fork). SeparateRsrc mode writes the raw
1846        // resource fork to a `<name>.rsrc` sidecar — before the trait wiring this
1847        // sidecar was empty / never written for MFS files.
1848        use crate::fork_export::export_file_with_fork;
1849        use crate::resource_fork::ResourceForkMode;
1850
1851        let dir = std::env::temp_dir().join(format!("rb_mfs_fork_{}", std::process::id()));
1852        let _ = std::fs::remove_dir_all(&dir);
1853        std::fs::create_dir_all(&dir).unwrap();
1854
1855        let mut fs = MfsFilesystem::open(Cursor::new(build_test_mfs()), 0).unwrap();
1856        let root = fs.root().unwrap();
1857        let doc = fs
1858            .list_directory(&root)
1859            .unwrap()
1860            .into_iter()
1861            .find(|e| e.name == "Doc")
1862            .unwrap();
1863
1864        export_file_with_fork(&mut fs, &doc, &dir, "Doc", ResourceForkMode::SeparateRsrc).unwrap();
1865
1866        // Data fork under the plain name, plus a NON-EMPTY resource-fork sidecar
1867        // carrying the real bytes.
1868        assert_eq!(std::fs::read(dir.join("Doc")).unwrap(), b"first line\n");
1869        assert_eq!(std::fs::read(dir.join("Doc.rsrc")).unwrap(), b"RSRC");
1870
1871        let _ = std::fs::remove_dir_all(&dir);
1872    }
1873
1874    #[test]
1875    fn create_file_with_resource_fork_persists() {
1876        // The put-binhex / put-macbinary path: create_file with a resource fork
1877        // in CreateFileOptions. Must allocate + write a second chain and survive
1878        // sync + reopen — before this, MFS create_file dropped the fork.
1879        use disk_core::filesystem::ResourceForkSource;
1880        let mut cur = Cursor::new(build_test_mfs());
1881        {
1882            let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
1883            let root = fs.root().unwrap();
1884            let opts = CreateFileOptions {
1885                resource_fork: Some(ResourceForkSource::Data(b"NEW-RSRC-BYTES".to_vec())),
1886                ..Default::default()
1887            };
1888            let data = b"data fork content";
1889            let fe = fs
1890                .create_file(&root, "Forked", &mut &data[..], data.len() as u64, &opts)
1891                .unwrap();
1892            assert_eq!(fe.resource_fork_size, Some(14));
1893            fs.sync_metadata().unwrap();
1894        }
1895        cur.set_position(0);
1896        let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
1897        let root = fs.root().unwrap();
1898        let forked = fs
1899            .list_directory(&root)
1900            .unwrap()
1901            .into_iter()
1902            .find(|e| e.name == "Forked")
1903            .unwrap();
1904        assert_eq!(forked.resource_fork_size, Some(14));
1905        assert_eq!(
1906            fs.read_file(&forked, usize::MAX).unwrap(),
1907            b"data fork content"
1908        );
1909        // Through the trait, as the export consumers read it back.
1910        let fsys: &mut dyn Filesystem = &mut fs;
1911        let mut buf = Vec::new();
1912        fsys.write_resource_fork_to(&forked, &mut buf).unwrap();
1913        assert_eq!(&buf, b"NEW-RSRC-BYTES");
1914    }
1915
1916    #[test]
1917    fn write_resource_fork_sets_and_replaces() {
1918        // The setrsrc / batch path: set a fork on a fork-less file, then replace
1919        // it with a different-sized one (frees the old chain first).
1920        let mut cur = Cursor::new(build_test_mfs());
1921        {
1922            let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
1923            let root = fs.root().unwrap();
1924            let fe = fs
1925                .create_file(
1926                    &root,
1927                    "SetR",
1928                    &mut &b"body"[..],
1929                    4,
1930                    &CreateFileOptions::default(),
1931                )
1932                .unwrap();
1933            fs.write_resource_fork(&fe, &mut &b"FIRST"[..], 5).unwrap();
1934            fs.sync_metadata().unwrap();
1935        }
1936        cur.set_position(0);
1937        {
1938            let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
1939            let root = fs.root().unwrap();
1940            let e = fs
1941                .list_directory(&root)
1942                .unwrap()
1943                .into_iter()
1944                .find(|e| e.name == "SetR")
1945                .unwrap();
1946            assert_eq!(fs.read_resource_fork(&e).unwrap(), b"FIRST");
1947            fs.write_resource_fork(&e, &mut &b"SECOND-and-longer"[..], 17)
1948                .unwrap();
1949            fs.sync_metadata().unwrap();
1950        }
1951        cur.set_position(0);
1952        let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
1953        let root = fs.root().unwrap();
1954        let e = fs
1955            .list_directory(&root)
1956            .unwrap()
1957            .into_iter()
1958            .find(|e| e.name == "SetR")
1959            .unwrap();
1960        assert_eq!(fs.read_resource_fork(&e).unwrap(), b"SECOND-and-longer");
1961    }
1962
1963    #[test]
1964    fn rejects_non_mfs_signature() {
1965        let mut disk = build_test_mfs();
1966        disk[1024] = 0xFF;
1967        disk[1025] = 0xFF;
1968        let cur = Cursor::new(disk);
1969        match MfsFilesystem::open(cur, 0) {
1970            Ok(_) => panic!("expected open to fail on bad signature"),
1971            Err(FilesystemError::InvalidData(msg)) => {
1972                assert!(msg.contains("bad MFS signature"), "got: {msg}");
1973            }
1974            Err(other) => panic!("expected InvalidData, got {other:?}"),
1975        }
1976    }
1977
1978    // ------------------------------------------------------------------
1979    // create-blank + fsck
1980    // ------------------------------------------------------------------
1981
1982    #[test]
1983    fn create_blank_400k_round_trips_and_fscks_clean() {
1984        let img = create_blank_mfs(MFS_400K_BYTES, "Blank400").unwrap();
1985        assert_eq!(img.len() as u64, MFS_400K_BYTES);
1986        let mut fs = MfsFilesystem::open(Cursor::new(img), 0).unwrap();
1987        assert_eq!(fs.fs_type(), "MFS");
1988        assert_eq!(fs.volume_label(), Some("Blank400"));
1989        assert_eq!(fs.mdb.num_files, 0);
1990        assert_eq!(fs.mdb.free_blocks, fs.mdb.num_alloc_blocks);
1991        let root = fs.root().unwrap();
1992        assert!(fs.list_directory(&root).unwrap().is_empty());
1993        let res = fs.fsck().unwrap().unwrap();
1994        assert!(
1995            res.is_clean(),
1996            "blank 400K should fsck clean: {:?}",
1997            res.errors
1998        );
1999    }
2000
2001    #[test]
2002    fn create_blank_geometry_valid_across_sizes() {
2003        for size in [
2004            MFS_400K_BYTES,
2005            MFS_800K_BYTES,
2006            1_474_560, // 1.44 MB
2007            4 * 1024 * 1024,
2008            20 * 1024 * 1024,
2009        ] {
2010            let img = create_blank_mfs(size, "Vol").unwrap();
2011            let mut fs = MfsFilesystem::open(Cursor::new(img), 0).unwrap();
2012            // Data region must fit inside the volume.
2013            let (first_ab, nab, abs) = (
2014                fs.mdb.first_alloc_block_sector as u64,
2015                fs.mdb.num_alloc_blocks as u64,
2016                fs.mdb.alloc_block_size as u64,
2017            );
2018            let last_sector = first_ab + nab * (abs / 512);
2019            assert!(
2020                last_sector <= size / 512,
2021                "size {size}: data region ends at sector {last_sector}, past {}",
2022                size / 512
2023            );
2024            assert!(nab <= 4094, "size {size}: too many blocks");
2025            let res = fs.fsck().unwrap().unwrap();
2026            assert!(
2027                res.is_clean(),
2028                "size {size} should fsck clean: {:?}",
2029                res.errors
2030            );
2031        }
2032    }
2033
2034    #[test]
2035    fn create_blank_too_small_is_rejected() {
2036        assert!(create_blank_mfs(8 * 1024, "X").is_err());
2037    }
2038
2039    #[test]
2040    fn blank_volume_accepts_files_and_stays_clean() {
2041        let img = create_blank_mfs(MFS_400K_BYTES, "Work").unwrap();
2042        let mut cur = Cursor::new(img);
2043        {
2044            let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
2045            let root = fs.root().unwrap();
2046            fs.create_file(
2047                &root,
2048                "ReadMe",
2049                &mut &b"hello from a blank MFS volume"[..],
2050                29,
2051                &CreateFileOptions::default(),
2052            )
2053            .unwrap();
2054            fs.sync_metadata().unwrap();
2055        }
2056        cur.set_position(0);
2057        let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
2058        let root = fs.root().unwrap();
2059        let entries = fs.list_directory(&root).unwrap();
2060        assert_eq!(entries.len(), 1);
2061        assert_eq!(entries[0].name, "ReadMe");
2062        assert_eq!(
2063            fs.read_file(&entries[0], usize::MAX).unwrap(),
2064            b"hello from a blank MFS volume"
2065        );
2066        let res = fs.fsck().unwrap().unwrap();
2067        assert!(
2068            res.is_clean(),
2069            "populated volume should fsck clean: {:?}",
2070            res.errors
2071        );
2072    }
2073
2074    #[test]
2075    fn fsck_clean_on_synthetic_volume() {
2076        let mut fs = MfsFilesystem::open(Cursor::new(build_test_mfs()), 0).unwrap();
2077        let res = fs.fsck().unwrap().unwrap();
2078        assert!(res.is_clean(), "expected clean, got {:?}", res.errors);
2079        assert_eq!(res.stats.files_checked, 2);
2080    }
2081
2082    #[test]
2083    fn fsck_detects_and_repairs_leaked_block() {
2084        let mut cur = Cursor::new(build_test_mfs());
2085        {
2086            let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
2087            // Mark a currently-free block allocated (end-of-chain) without any
2088            // directory entry referencing it: a leaked block.
2089            fs.map_set(6, 1);
2090            let res = fs.fsck().unwrap().unwrap();
2091            assert!(!res.is_clean());
2092            assert!(res
2093                .errors
2094                .iter()
2095                .any(|e| e.code == "MfsLostBlocks" && e.repairable));
2096            assert!(res.repairable);
2097            let report = fs.repair().unwrap();
2098            assert_eq!(report.unrepairable_count, 0);
2099            assert!(!report.fixes_applied.is_empty());
2100            fs.reader.flush().unwrap();
2101        }
2102        // Reopen from disk: the reclaimed block is now free and fsck is clean.
2103        cur.set_position(0);
2104        let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
2105        assert_eq!(fs.map_get(6), 0, "block 6 reclaimed");
2106        let res = fs.fsck().unwrap().unwrap();
2107        assert!(
2108            res.is_clean(),
2109            "post-repair should be clean: {:?}",
2110            res.errors
2111        );
2112    }
2113
2114    #[test]
2115    fn fsck_detects_and_repairs_file_count() {
2116        let mut cur = Cursor::new(build_test_mfs());
2117        {
2118            let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
2119            fs.mdb.num_files = 9; // wrong (actual: 2)
2120            let res = fs.fsck().unwrap().unwrap();
2121            assert!(res
2122                .errors
2123                .iter()
2124                .any(|e| e.code == "MfsFileCountMismatch" && e.repairable));
2125            fs.repair().unwrap();
2126            fs.reader.flush().unwrap();
2127        }
2128        cur.set_position(0);
2129        let mut fs = MfsFilesystem::open(&mut cur, 0).unwrap();
2130        assert_eq!(fs.mdb.num_files, 2);
2131        assert!(fs.fsck().unwrap().unwrap().is_clean());
2132    }
2133
2134    #[test]
2135    fn fsck_withholds_repair_on_broken_chain() {
2136        let mut fs = MfsFilesystem::open(Cursor::new(build_test_mfs()), 0).unwrap();
2137        // Point a file's data fork past the end of the volume.
2138        fs.entries[0].data_first_block = 50; // last valid block is 9
2139        let res = fs.fsck().unwrap().unwrap();
2140        assert!(res.errors.iter().any(|e| e.code == "MfsBrokenChain"));
2141        assert!(!res.repairable, "a broken chain must not be repairable");
2142        let report = fs.repair().unwrap();
2143        assert!(report.fixes_applied.is_empty());
2144        assert!(report.unrepairable_count >= 1);
2145    }
2146
2147    #[test]
2148    fn map_get_round_trip() {
2149        // Confirm our 12-bit packing round-trips through `MfsFilesystem::map_get`.
2150        let disk = build_test_mfs();
2151        let cur = Cursor::new(disk);
2152        let fs = MfsFilesystem::open(cur, 0).unwrap();
2153        assert_eq!(fs.map_get(2), 1, "block 2 marked end-of-chain");
2154        assert_eq!(fs.map_get(3), 1);
2155        assert_eq!(fs.map_get(4), 1);
2156        assert_eq!(fs.map_get(5), 0, "block 5 marked free");
2157        assert_eq!(fs.map_get(0), 0, "reserved block 0");
2158        assert_eq!(fs.map_get(1), 0, "reserved block 1");
2159    }
2160
2161    #[test]
2162    fn total_and_used_size_reflect_mdb_counts() {
2163        let disk = build_test_mfs();
2164        let cur = Cursor::new(disk);
2165        let fs = MfsFilesystem::open(cur, 0).unwrap();
2166        assert_eq!(fs.total_size(), 8 * 1024);
2167        // 3 allocation blocks used (Hello data, Doc data, Doc rsrc).
2168        assert_eq!(fs.used_size(), 3 * 1024);
2169    }
2170
2171    // ------------------------------------------------------------------
2172    // EditableFilesystem — create / delete / round-trip
2173    // ------------------------------------------------------------------
2174
2175    /// Build a fresh test volume already inside a `Cursor<Vec<u8>>`. The
2176    /// cursor is the in-memory backing store the edit tests mutate
2177    /// through `EditableFilesystem`, then reopen for read-side
2178    /// verification.
2179    fn edit_fixture() -> Cursor<Vec<u8>> {
2180        Cursor::new(build_test_mfs())
2181    }
2182
2183    #[test]
2184    fn create_file_writes_data_and_round_trips_through_sync() {
2185        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2186        let root = fs.root().unwrap();
2187        let before_free = EditableFilesystem::free_space(&mut fs).unwrap();
2188        let payload = b"freshly-created via EditableFilesystem".to_vec();
2189        let mut src = Cursor::new(payload.clone());
2190        let opts = CreateFileOptions::default();
2191        let fe = fs
2192            .create_file(&root, "NewFile", &mut src, payload.len() as u64, &opts)
2193            .unwrap();
2194        assert_eq!(fe.name, "NewFile");
2195        assert_eq!(fe.size, payload.len() as u64);
2196
2197        fs.sync_metadata().unwrap();
2198        // Reopen and prove the file persisted across a fresh parse.
2199        let inner = fs.reader.clone();
2200        let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
2201        let root2 = fs2.root().unwrap();
2202        let entries = fs2.list_directory(&root2).unwrap();
2203        let new = entries.iter().find(|e| e.name == "NewFile").unwrap();
2204        let read_back = fs2.read_file(new, 4096).unwrap();
2205        assert_eq!(read_back, payload);
2206
2207        // Free-space accounting: one alloc-block consumed (payload < 1024 B).
2208        let after_free = EditableFilesystem::free_space(&mut fs2).unwrap();
2209        assert_eq!(after_free, before_free - 1024);
2210    }
2211
2212    #[test]
2213    fn delete_entry_releases_blocks_and_clears_directory() {
2214        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2215        let root = fs.root().unwrap();
2216        let before_free = EditableFilesystem::free_space(&mut fs).unwrap();
2217        let entries = fs.list_directory(&root).unwrap();
2218        let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
2219
2220        fs.delete_entry(&root, &doc).unwrap();
2221        fs.sync_metadata().unwrap();
2222
2223        let inner = fs.reader.clone();
2224        let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
2225        let root2 = fs2.root().unwrap();
2226        let entries = fs2.list_directory(&root2).unwrap();
2227        assert!(
2228            entries.iter().all(|e| e.name != "Doc"),
2229            "Doc should be gone after delete"
2230        );
2231
2232        // "Doc" used 2 blocks (data fork + resource fork) at 1024 B each.
2233        let after_free = EditableFilesystem::free_space(&mut fs2).unwrap();
2234        assert_eq!(after_free, before_free + 2 * 1024);
2235    }
2236
2237    #[test]
2238    fn rename_in_place_round_trips_through_sync() {
2239        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2240        let root = fs.root().unwrap();
2241        let entries = fs.list_directory(&root).unwrap();
2242        let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
2243        let fnum = doc.location;
2244
2245        fs.rename(&root, &doc, "Report").unwrap();
2246        fs.sync_metadata().unwrap();
2247
2248        // Reopen and prove the rename persisted with identity + contents intact.
2249        let inner = fs.reader.clone();
2250        let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
2251        let root2 = fs2.root().unwrap();
2252        let entries2 = fs2.list_directory(&root2).unwrap();
2253        let names: Vec<&str> = entries2.iter().map(|e| e.name.as_str()).collect();
2254        assert!(names.contains(&"Report"));
2255        assert!(!names.contains(&"Doc"));
2256
2257        let renamed = entries2.iter().find(|e| e.name == "Report").unwrap();
2258        // Same file number (identity); both forks + resource size preserved.
2259        assert_eq!(renamed.location, fnum);
2260        assert_eq!(renamed.resource_fork_size, Some(4));
2261        assert_eq!(fs2.read_file(renamed, 1024).unwrap(), b"first line\n");
2262        assert_eq!(fs2.read_resource_fork(renamed).unwrap(), b"RSRC");
2263    }
2264
2265    /// H12: duplicate detection compared bytes, so "hello" could be created
2266    /// beside "Hello" and the Finder then saw two files it could not tell apart.
2267    #[test]
2268    fn names_differing_only_in_case_are_the_same_file() {
2269        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2270        let root = fs.root().unwrap();
2271        let err = fs
2272            .create_file(
2273                &root,
2274                "hello",
2275                &mut &b"x"[..],
2276                1,
2277                &CreateFileOptions::default(),
2278            )
2279            .unwrap_err();
2280        assert!(format!("{err}").contains("already exists"), "{err}");
2281        let entries = fs.list_directory(&root).unwrap();
2282        let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
2283        let err = fs.rename(&root, &doc, "HELLO").unwrap_err();
2284        assert!(matches!(err, FilesystemError::AlreadyExists(_)));
2285        // A file may still change its own capitalisation.
2286        fs.rename(&root, &doc, "DOC").unwrap();
2287    }
2288
2289    #[test]
2290    fn rename_rejects_collision() {
2291        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2292        let root = fs.root().unwrap();
2293        let entries = fs.list_directory(&root).unwrap();
2294        let doc = entries.iter().find(|e| e.name == "Doc").unwrap().clone();
2295        // "Hello" already exists; renaming "Doc" onto it must be rejected.
2296        let err = fs.rename(&root, &doc, "Hello").unwrap_err();
2297        assert!(matches!(err, FilesystemError::AlreadyExists(_)));
2298    }
2299
2300    #[test]
2301    fn create_file_rejects_duplicate_names() {
2302        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2303        let root = fs.root().unwrap();
2304        let mut src = Cursor::new(b"x".to_vec());
2305        let err = fs
2306            .create_file(&root, "Hello", &mut src, 1, &CreateFileOptions::default())
2307            .unwrap_err();
2308        assert!(matches!(err, FilesystemError::InvalidData(_)));
2309    }
2310
2311    #[test]
2312    fn create_file_rejects_oversize_data() {
2313        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2314        let root = fs.root().unwrap();
2315        // The fixture has 5 free blocks of 1024 B each = 5 KiB free.
2316        // Try to write 8 KiB — must fail with InvalidData.
2317        let huge = vec![0u8; 8 * 1024];
2318        let mut src = Cursor::new(huge);
2319        let err = fs
2320            .create_file(
2321                &root,
2322                "TooBig",
2323                &mut src,
2324                8 * 1024,
2325                &CreateFileOptions::default(),
2326            )
2327            .unwrap_err();
2328        assert!(matches!(err, FilesystemError::InvalidData(_)));
2329    }
2330
2331    #[test]
2332    fn create_then_delete_returns_to_original_free_count() {
2333        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2334        let root = fs.root().unwrap();
2335        let before = EditableFilesystem::free_space(&mut fs).unwrap();
2336        let mut src = Cursor::new(b"abc".to_vec());
2337        let fe = fs
2338            .create_file(&root, "Tmp", &mut src, 3, &CreateFileOptions::default())
2339            .unwrap();
2340        let mid = EditableFilesystem::free_space(&mut fs).unwrap();
2341        assert_eq!(mid, before - 1024);
2342        // delete_entry needs a parent + the synthesized FileEntry from create.
2343        fs.delete_entry(&root, &fe).unwrap();
2344        let after = EditableFilesystem::free_space(&mut fs).unwrap();
2345        assert_eq!(after, before, "free count must return after create+delete");
2346    }
2347
2348    #[test]
2349    fn create_directory_is_unsupported() {
2350        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2351        let root = fs.root().unwrap();
2352        let err = fs
2353            .create_directory(&root, "subdir", &CreateDirectoryOptions::default())
2354            .unwrap_err();
2355        assert!(matches!(err, FilesystemError::Unsupported(_)));
2356    }
2357
2358    #[test]
2359    fn set_type_creator_updates_finder_info_and_persists() {
2360        let mut fs = MfsFilesystem::open(edit_fixture(), 0).unwrap();
2361        let root = fs.root().unwrap();
2362        let entries = fs.list_directory(&root).unwrap();
2363        let hello = entries.iter().find(|e| e.name == "Hello").unwrap().clone();
2364        fs.set_type_creator(&hello, "PICT", "8BIM").unwrap();
2365        fs.sync_metadata().unwrap();
2366
2367        let inner = fs.reader.clone();
2368        let mut fs2 = MfsFilesystem::open(inner, 0).unwrap();
2369        let root2 = fs2.root().unwrap();
2370        let entries = fs2.list_directory(&root2).unwrap();
2371        let hello2 = entries.iter().find(|e| e.name == "Hello").unwrap();
2372        assert_eq!(hello2.type_code, Some(*b"PICT"));
2373        assert_eq!(hello2.creator_code, Some(*b"8BIM"));
2374    }
2375}
2376
2377#[cfg(test)]
2378mod physical_length_tests {
2379    use super::*;
2380    use disk_core::filesystem::{CreateFileOptions, Filesystem};
2381    use std::io::Cursor;
2382
2383    /// H6: flPyLen / flRPyLen were written as the logical lengths; Mac OS
2384    /// expects the allocated size, whole allocation blocks.
2385    #[test]
2386    fn physical_lengths_are_whole_allocation_blocks() {
2387        let img = create_blank_mfs(400 * 1024, "T").unwrap();
2388        let mut fs = MfsFilesystem::open(Cursor::new(img), 0).unwrap();
2389        let root = fs.root().unwrap();
2390        let mut data = Cursor::new(vec![0x5Au8; 100]);
2391        fs.create_file(&root, "tiny", &mut data, 100, &CreateFileOptions::default())
2392            .unwrap();
2393        fs.sync_metadata().unwrap();
2394        let abs = fs.mdb.alloc_block_size;
2395        let off = fs.partition_offset + fs.mdb.dir_start_sector as u64 * 512;
2396        fs.reader.seek(SeekFrom::Start(off)).unwrap();
2397        let mut sector = [0u8; 512];
2398        fs.reader.read_exact(&mut sector).unwrap();
2399        assert_ne!(sector[0] & 0x80, 0, "first directory entry is in use");
2400        assert_eq!(
2401            BigEndian::read_u32(&sector[24..28]),
2402            100,
2403            "logical data length"
2404        );
2405        assert_eq!(
2406            BigEndian::read_u32(&sector[28..32]),
2407            abs,
2408            "physical data length"
2409        );
2410        assert_eq!(BigEndian::read_u32(&sector[38..42]), 0, "no resource fork");
2411    }
2412}