Skip to main content

fs_mac/
lisa.rs

1//! Apple Lisa File System — read-only browse + extract.
2//!
3//! Lisa 3.5" floppies (Sony 400K / 800K GCR, 512-byte sectors) are distributed
4//! as DiskCopy 4.2 images that *retain* the 12-byte-per-sector **tag** region.
5//! Those tags are the key to the volume: each sector's tag carries the **file
6//! ID** it belongs to and the file-**relative block** index, so a file is
7//! reconstructed by gathering every sector with a given file ID and ordering
8//! them by relative block — no dependence on the (three different, version-
9//! specific) catalog structures. This mirrors Ray Arachelian's `lisafsh-tool`,
10//! the reference implementation.
11//!
12//! Friendly file names come from the flat catalog (file ID `0x0004`): 54-byte
13//! entries, a Pascal-string name at offset 0 and the file ID as a big-endian
14//! `u16` at offset 36. This works for the flat-table (`0x0e`) and flat-hash
15//! (`0x0f`) filesystem versions; the hierarchical B-tree version (`0x11`) keeps
16//! its catalog elsewhere, so those volumes fall back to `file-XXXX` names while
17//! extraction still works for every file.
18//!
19//! ## Limitations (shared with the reference tool)
20//! - **Read-only.** Writing would require maintaining tags plus one of three
21//!   catalog formats; out of scope.
22//! - **Sector-granular sizes.** The exact end-of-file (the "extents" tail of up
23//!   to 511 bytes) isn't tracked, so extracted files are padded to a whole
24//!   sector.
25//! - **Tags required.** DiskCopy versions after 4.2 strip tags; such an image
26//!   can't be browsed (detection fails cleanly).
27
28#[cfg(feature = "rust173-polyfill")]
29use disk_core::rust173::IntIsMultipleOf as _;
30use std::collections::{BTreeMap, BTreeSet};
31use std::io::{Read, Seek, SeekFrom, Write};
32
33use disk_core::entry::FileEntry;
34use disk_core::filesystem::{Filesystem, FilesystemError};
35use dsk_apple::dc42;
36
37const SECTOR_SIZE: usize = 512;
38const TAG_SIZE: usize = 12;
39/// Normal files carry a 0xF0-byte metadata "label" on their first sector; it is
40/// stripped from the reconstructed data fork (matching `lisafsh-tool`).
41const LABEL_LEN: usize = 0xF0;
42/// Catalog (directory) entry stride, in bytes.
43const CATALOG_ENTRY: usize = 54;
44/// Offset of the big-endian file-ID within a 54-byte catalog entry.
45const CATALOG_FILEID_OFF: usize = 36;
46
47// Reserved / special file IDs (see `lisafsh-tool`).
48const FID_FREE: u16 = 0x0000;
49const FID_MDDF: u16 = 0x0001;
50const FID_BITMAP: u16 = 0x0002;
51const FID_SRECORDS: u16 = 0x0003;
52const FID_DIRECTORY: u16 = 0x0004;
53const FID_ERASED: u16 = 0x7fff;
54const FID_MAXTAG: u16 = 0x7fff;
55const FID_BOOT: u16 = 0xaaaa;
56const FID_OSLOADER: u16 = 0xbbbb;
57
58fn be16(b: &[u8], o: usize) -> u16 {
59    ((b[o] as u16) << 8) | (b[o + 1] as u16)
60}
61
62/// One reconstructed Lisa file (or system pseudo-file), keyed by file ID.
63struct LisaEntry {
64    fileid: u16,
65    name: String,
66    /// Sector indices in file-relative-block order.
67    sectors: Vec<usize>,
68    /// Strip the 0xF0 metadata label from the first sector (normal files only).
69    strip_label: bool,
70    /// Reconstructed length in bytes (sector-granular).
71    size: u64,
72}
73
74/// A read-only view over an Apple Lisa File System floppy image.
75pub struct LisaFilesystem {
76    numblocks: usize,
77    data: Vec<u8>,
78    tags: Vec<u8>,
79    volume_name: String,
80    fs_version_name: &'static str,
81    /// Indexed by `FileEntry::location`.
82    entries: Vec<LisaEntry>,
83    free_blocks: usize,
84}
85
86/// Human label for an MDDF `fsversion`.
87fn fsversion_name(v: u16) -> &'static str {
88    match v {
89        0x0e => "Lisa FS (flat table)",
90        0x0f => "Lisa FS (flat hash)",
91        0x11 => "Lisa FS (B-tree)",
92        _ => "Lisa FS",
93    }
94}
95
96fn is_valid_fsversion(v: u16) -> bool {
97    matches!(v, 0x0e | 0x0f | 0x11)
98}
99
100/// Friendly name for a reserved file ID; `None` for ordinary files (which get
101/// their name from the catalog, falling back to `file-XXXX`).
102fn reserved_name(fileid: u16) -> Option<&'static str> {
103    match fileid {
104        FID_FREE => Some("freeblocks-0000"),
105        FID_MDDF => Some("MDDF-0001"),
106        FID_BITMAP => Some("alloc-bitmap-0002"),
107        FID_SRECORDS => Some("srecords-0003"),
108        FID_DIRECTORY => Some("directory-0004"),
109        FID_ERASED => Some("deleted-7fff"),
110        FID_BOOT => Some("bootsect-aaaa"),
111        FID_OSLOADER => Some("OSLoader-bbbb"),
112        _ => None,
113    }
114}
115
116/// A "normal" user file — the catalog names it and its first sector holds a
117/// 0xF0-byte metadata label. Reserved IDs (`<= 0x0004`, the erased marker, and
118/// everything `> 0x7fff`) are system scaffolding.
119fn is_normal_file(fileid: u16) -> bool {
120    fileid > FID_DIRECTORY && fileid < FID_ERASED
121}
122
123impl LisaFilesystem {
124    /// Open a Lisa File System image. `reader` must cover the **whole**
125    /// DiskCopy 4.2 file (header + data + tags) — not the unwrapped data region,
126    /// which would have discarded the tags this filesystem depends on.
127    pub fn open<R: Read + Seek>(mut reader: R) -> Result<Self, FilesystemError> {
128        reader
129            .seek(SeekFrom::Start(0))
130            .map_err(FilesystemError::Io)?;
131        let header = dc42::parse_dc42_header(&mut reader)
132            .ok_or_else(|| FilesystemError::Parse("not a DiskCopy 4.2 image".into()))?;
133
134        let data_size = header.data_size as usize;
135        if data_size == 0 || !data_size.is_multiple_of(SECTOR_SIZE) {
136            return Err(FilesystemError::Parse(format!(
137                "unexpected data size {data_size}"
138            )));
139        }
140        let numblocks = data_size / SECTOR_SIZE;
141        if header.tag_size as usize != numblocks * TAG_SIZE {
142            return Err(FilesystemError::Parse(
143                "image has no Lisa tags (post-4.2 DiskCopy strips them) - cannot browse".into(),
144            ));
145        }
146
147        let mut data = vec![0u8; data_size];
148        reader.read_exact(&mut data).map_err(FilesystemError::Io)?;
149        let mut tags = vec![0u8; numblocks * TAG_SIZE];
150        reader.read_exact(&mut tags).map_err(FilesystemError::Io)?;
151
152        Self::from_parts(data, tags)
153    }
154
155    /// Construct directly from a de-containerized `(data, tags)` pair — the
156    /// media-agnostic seam any producer can target: DiskCopy 4.2 today (via
157    /// [`open`](LisaFilesystem::open)), a DART decompressor or a Twiggy
158    /// de-interleaver later. `data` must be `numblocks * 512` bytes and `tags`
159    /// exactly `numblocks * 12`.
160    pub fn from_parts(data: Vec<u8>, tags: Vec<u8>) -> Result<Self, FilesystemError> {
161        if data.is_empty() || !data.len().is_multiple_of(SECTOR_SIZE) {
162            return Err(FilesystemError::Parse(format!(
163                "unexpected data length {}",
164                data.len()
165            )));
166        }
167        let numblocks = data.len() / SECTOR_SIZE;
168        if tags.len() != numblocks * TAG_SIZE {
169            return Err(FilesystemError::Parse(format!(
170                "tag length {} != numblocks {} * {}",
171                tags.len(),
172                numblocks,
173                TAG_SIZE
174            )));
175        }
176
177        let mut fs = LisaFilesystem {
178            numblocks,
179            data,
180            tags,
181            volume_name: String::new(),
182            fs_version_name: "Lisa FS",
183            entries: Vec::new(),
184            free_blocks: 0,
185        };
186        fs.build()?;
187        Ok(fs)
188    }
189
190    fn tag_fileid(&self, sector: usize) -> u16 {
191        be16(&self.tags, sector * TAG_SIZE + 4)
192    }
193
194    /// File-relative block index (masked to 11 bits, as `lisafsh-tool` does).
195    fn tag_relblock(&self, sector: usize) -> u16 {
196        be16(&self.tags, sector * TAG_SIZE + 6) & 0x7ff
197    }
198
199    fn sector_bytes(&self, sector: usize) -> &[u8] {
200        &self.data[sector * SECTOR_SIZE..(sector + 1) * SECTOR_SIZE]
201    }
202
203    fn build(&mut self) -> Result<(), FilesystemError> {
204        // Group sectors by file ID, ordered by file-relative block.
205        let mut by_file: BTreeMap<u16, Vec<usize>> = BTreeMap::new();
206        let mut free_blocks = 0usize;
207        for n in 0..self.numblocks {
208            let fid = self.tag_fileid(n);
209            if fid == FID_FREE {
210                free_blocks += 1;
211            }
212            by_file.entry(fid).or_default().push(n);
213        }
214        for secs in by_file.values_mut() {
215            secs.sort_by_key(|&n| self.tag_relblock(n));
216        }
217        self.free_blocks = free_blocks;
218
219        // MDDF: volume name + filesystem version.
220        if let Some(secs) = by_file.get(&FID_MDDF) {
221            if let Some(&n) = secs.first() {
222                let (ver, vol) = {
223                    let s = self.sector_bytes(n);
224                    (fsversion_name(be16(s, 0)), read_mddf_volume_name(s))
225                };
226                self.fs_version_name = ver;
227                self.volume_name = vol;
228            }
229        }
230
231        // Catalog names (flat table / hash only; B-tree has no 0x0004 blocks).
232        let real_files: BTreeSet<u16> = by_file
233            .keys()
234            .copied()
235            .filter(|&f| is_normal_file(f))
236            .collect();
237        let names = self.read_catalog_names(by_file.get(&FID_DIRECTORY), &real_files);
238
239        // Build the browsable entry list: user files plus recognised system
240        // files, skipping free/erased blocks and unnamed high-ID scaffolding.
241        for (&fileid, sectors) in &by_file {
242            if !should_list(fileid) {
243                continue;
244            }
245            let strip_label = is_normal_file(fileid);
246            let name = names
247                .get(&fileid)
248                .cloned()
249                .or_else(|| reserved_name(fileid).map(String::from))
250                .unwrap_or_else(|| format!("file-{fileid:04x}"));
251            let raw = sectors.len() * SECTOR_SIZE;
252            let size = if strip_label {
253                raw.saturating_sub(LABEL_LEN)
254            } else {
255                raw
256            } as u64;
257            self.entries.push(LisaEntry {
258                fileid,
259                name,
260                sectors: sectors.clone(),
261                strip_label,
262                size,
263            });
264        }
265        // Stable, predictable order: by file ID.
266        self.entries.sort_by_key(|e| e.fileid);
267        Ok(())
268    }
269
270    /// Parse the flat catalog into a file-ID → name map. Scans 54-byte entries
271    /// (Pascal name at offset 0, file ID at offset 36), accepting only entries
272    /// whose file ID is a real file present in the tags.
273    fn read_catalog_names(
274        &self,
275        dir_sectors: Option<&Vec<usize>>,
276        real_files: &BTreeSet<u16>,
277    ) -> BTreeMap<u16, String> {
278        let mut names = BTreeMap::new();
279        let dir_sectors = match dir_sectors {
280            Some(s) if !s.is_empty() => s,
281            _ => return names,
282        };
283        let mut cat = Vec::with_capacity(dir_sectors.len() * SECTOR_SIZE);
284        for &n in dir_sectors {
285            cat.extend_from_slice(self.sector_bytes(n));
286        }
287
288        let mut o = 0usize;
289        while o + CATALOG_ENTRY <= cat.len() {
290            let len = cat[o] as usize;
291            let name_ok = (1..=31).contains(&len)
292                && cat[o + 1..o + 1 + len]
293                    .iter()
294                    .all(|&c| (32..127).contains(&c));
295            if name_ok {
296                let fileid = be16(&cat, o + CATALOG_FILEID_OFF);
297                if real_files.contains(&fileid) {
298                    let name: String = cat[o + 1..o + 1 + len].iter().map(|&c| c as char).collect();
299                    names.entry(fileid).or_insert(name);
300                    o += CATALOG_ENTRY;
301                    continue;
302                }
303            }
304            o += 2;
305        }
306        names
307    }
308
309    /// Reconstruct a file's bytes by concatenating its sectors in order,
310    /// stripping the metadata label from the first sector for normal files.
311    fn reconstruct(&self, entry: &LisaEntry, max_bytes: usize) -> Vec<u8> {
312        let mut out = Vec::with_capacity(entry.size as usize);
313        for (i, &sec) in entry.sectors.iter().enumerate() {
314            if out.len() >= max_bytes {
315                break;
316            }
317            let s = self.sector_bytes(sec);
318            let start = if i == 0 && entry.strip_label {
319                LABEL_LEN.min(s.len())
320            } else {
321                0
322            };
323            out.extend_from_slice(&s[start..]);
324        }
325        out.truncate(max_bytes);
326        out
327    }
328
329    fn entry_at(&self, location: u64) -> Result<&LisaEntry, FilesystemError> {
330        self.entries
331            .get(location as usize)
332            .ok_or_else(|| FilesystemError::NotFound(format!("entry {location} out of range")))
333    }
334}
335
336/// Read the MDDF volume name (fixed field at 0x0d..0x20), trimming at the first
337/// non-printable byte and dropping trailing spaces.
338fn read_mddf_volume_name(mddf: &[u8]) -> String {
339    let field = &mddf[0x0d..0x20];
340    let end = field
341        .iter()
342        .position(|&c| !(32..127).contains(&c))
343        .unwrap_or(field.len());
344    String::from_utf8_lossy(&field[..end])
345        .trim_end()
346        .to_string()
347}
348
349/// Whether a file ID should appear in the browse listing: user files plus the
350/// recognised system files, but not free/erased blocks or the high-ID
351/// (`> 0x7fff`) internal scaffolding (except boot / OS-loader).
352fn should_list(fileid: u16) -> bool {
353    if fileid == FID_FREE || fileid == FID_ERASED {
354        return false;
355    }
356    if fileid > FID_MAXTAG {
357        return matches!(fileid, FID_BOOT | FID_OSLOADER);
358    }
359    true
360}
361
362/// Detect an Apple Lisa File System image: a DiskCopy 4.2 file whose tag region
363/// is present (12 bytes/sector) and contains a valid MDDF (file ID `0x0001`
364/// with a recognised `fsversion`). Leaves `reader` position undefined.
365pub fn looks_like_lisa<R: Read + Seek>(reader: &mut R) -> bool {
366    if reader.seek(SeekFrom::Start(0)).is_err() {
367        return false;
368    }
369    let header = match dc42::parse_dc42_header(reader) {
370        Some(h) => h,
371        None => return false,
372    };
373    let data_size = header.data_size as usize;
374    if data_size == 0 || !data_size.is_multiple_of(SECTOR_SIZE) {
375        return false;
376    }
377    let numblocks = data_size / SECTOR_SIZE;
378    if header.tag_size as usize != numblocks * TAG_SIZE {
379        return false;
380    }
381
382    // Read the tag region and scan for an MDDF whose sector carries a valid
383    // fsversion — the combination distinguishes Lisa from a tagged Mac GCR disk.
384    if reader
385        .seek(SeekFrom::Start(
386            (dc42::DC42_HEADER_SIZE as usize + data_size) as u64,
387        ))
388        .is_err()
389    {
390        return false;
391    }
392    let mut tags = vec![0u8; numblocks * TAG_SIZE];
393    if reader.read_exact(&mut tags).is_err() {
394        return false;
395    }
396    for n in 0..numblocks {
397        if be16(&tags, n * TAG_SIZE + 4) == FID_MDDF {
398            // Read just this sector's first two bytes (the fsversion).
399            let off = dc42::DC42_HEADER_SIZE as usize + n * SECTOR_SIZE;
400            if reader.seek(SeekFrom::Start(off as u64)).is_err() {
401                return false;
402            }
403            let mut v = [0u8; 2];
404            if reader.read_exact(&mut v).is_ok() && is_valid_fsversion(be16(&v, 0)) {
405                return true;
406            }
407        }
408    }
409    false
410}
411
412impl Filesystem for LisaFilesystem {
413    fn root(&mut self) -> Result<FileEntry, FilesystemError> {
414        Ok(FileEntry::new_directory("/".into(), "/".into(), 0))
415    }
416
417    fn list_directory(&mut self, entry: &FileEntry) -> Result<Vec<FileEntry>, FilesystemError> {
418        if entry.path != "/" {
419            return Ok(Vec::new());
420        }
421        // Lisa allows duplicate names; disambiguate the path with the file ID
422        // while keeping the display name intact.
423        let mut out = Vec::with_capacity(self.entries.len());
424        for (idx, e) in self.entries.iter().enumerate() {
425            let path = format!("/{} [{:04x}]", e.name, e.fileid);
426            let mut fe = FileEntry::new_file(e.name.clone(), path, e.size, idx as u64);
427            if !is_normal_file(e.fileid) {
428                fe.special_type = Some("system".into());
429            }
430            out.push(fe);
431        }
432        Ok(out)
433    }
434
435    fn read_file(
436        &mut self,
437        entry: &FileEntry,
438        max_bytes: usize,
439    ) -> Result<Vec<u8>, FilesystemError> {
440        let e = self.entry_at(entry.location)?;
441        Ok(self.reconstruct(e, max_bytes))
442    }
443
444    fn write_file_to(
445        &mut self,
446        entry: &FileEntry,
447        writer: &mut dyn Write,
448    ) -> Result<u64, FilesystemError> {
449        let e = self.entry_at(entry.location)?;
450        let mut written = 0u64;
451        for (i, &sec) in e.sectors.iter().enumerate() {
452            let s = self.sector_bytes(sec);
453            let start = if i == 0 && e.strip_label {
454                LABEL_LEN.min(s.len())
455            } else {
456                0
457            };
458            writer.write_all(&s[start..]).map_err(FilesystemError::Io)?;
459            written += (s.len() - start) as u64;
460        }
461        Ok(written)
462    }
463
464    fn fs_type(&self) -> &str {
465        self.fs_version_name
466    }
467
468    fn volume_label(&self) -> Option<&str> {
469        if self.volume_name.is_empty() {
470            None
471        } else {
472            Some(&self.volume_name)
473        }
474    }
475
476    fn total_size(&self) -> u64 {
477        (self.numblocks * SECTOR_SIZE) as u64
478    }
479
480    fn used_size(&self) -> u64 {
481        ((self.numblocks - self.free_blocks) * SECTOR_SIZE) as u64
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use std::io::Cursor;
489
490    /// Build a synthetic DiskCopy 4.2 Lisa image from a per-sector
491    /// `(fileid, relblock, data)` layout. `data` is copied into the sector at
492    /// offset 0.
493    fn build_image(sectors: &[(u16, u16, Vec<u8>)]) -> Vec<u8> {
494        let numblocks = sectors.len();
495        let data_size = numblocks * SECTOR_SIZE;
496        let tag_size = numblocks * TAG_SIZE;
497
498        let mut header = vec![0u8; 84];
499        let name = b"-lisa test-";
500        header[0] = name.len() as u8;
501        header[1..1 + name.len()].copy_from_slice(name);
502        header[64..68].copy_from_slice(&(data_size as u32).to_be_bytes());
503        header[68..72].copy_from_slice(&(tag_size as u32).to_be_bytes());
504        header[80] = 0; // 400K GCR
505        header[81] = 0x02;
506        header[82..84].copy_from_slice(&0x0100u16.to_be_bytes());
507
508        let mut data = vec![0u8; data_size];
509        let mut tags = vec![0u8; tag_size];
510        for (i, (fileid, relblock, bytes)) in sectors.iter().enumerate() {
511            let off = i * SECTOR_SIZE;
512            let n = bytes.len().min(SECTOR_SIZE);
513            data[off..off + n].copy_from_slice(&bytes[..n]);
514            tags[i * TAG_SIZE + 4..i * TAG_SIZE + 6].copy_from_slice(&fileid.to_be_bytes());
515            tags[i * TAG_SIZE + 6..i * TAG_SIZE + 8].copy_from_slice(&relblock.to_be_bytes());
516        }
517
518        let mut out = header;
519        out.extend_from_slice(&data);
520        out.extend_from_slice(&tags);
521        out
522    }
523
524    /// An MDDF sector: fsversion at 0, volume name at 0x0d.
525    fn mddf_sector(fsversion: u16, volume: &str) -> Vec<u8> {
526        let mut s = vec![0u8; SECTOR_SIZE];
527        s[0..2].copy_from_slice(&fsversion.to_be_bytes());
528        let v = volume.as_bytes();
529        let n = v.len().min(0x20 - 0x0d);
530        s[0x0d..0x0d + n].copy_from_slice(&v[..n]);
531        s
532    }
533
534    /// A catalog sector containing one entry (Pascal name at 0, file ID at 36).
535    fn catalog_sector(name: &str, fileid: u16) -> Vec<u8> {
536        let mut s = vec![0u8; SECTOR_SIZE];
537        let nm = name.as_bytes();
538        s[0] = nm.len() as u8;
539        s[1..1 + nm.len()].copy_from_slice(nm);
540        s[CATALOG_FILEID_OFF..CATALOG_FILEID_OFF + 2].copy_from_slice(&fileid.to_be_bytes());
541        s
542    }
543
544    /// A normal file's first sector: 0xF0 label then payload.
545    fn file_first_sector(label_marker: u8, payload: &[u8]) -> Vec<u8> {
546        let mut s = vec![label_marker; SECTOR_SIZE];
547        let n = payload.len().min(SECTOR_SIZE - LABEL_LEN);
548        s[LABEL_LEN..LABEL_LEN + n].copy_from_slice(&payload[..n]);
549        s
550    }
551
552    fn sample_image() -> Vec<u8> {
553        build_image(&[
554            (FID_BOOT, 0, b"BOOTBLOCK".to_vec()),                  // 0
555            (FID_MDDF, 0, mddf_sector(0x0f, "TESTVOL")),           // 1
556            (FID_DIRECTORY, 0, catalog_sector("HELLO", 0x0005)),   // 2
557            (0x0005, 0, file_first_sector(0xEE, b"Hello, Lisa!")), // 3
558            (0x0005, 1, b"SECOND-BLOCK".to_vec()),                 // 4
559            (FID_FREE, 0, vec![]),                                 // 5
560        ])
561    }
562
563    #[test]
564    fn detects_lisa_image() {
565        let img = sample_image();
566        assert!(looks_like_lisa(&mut Cursor::new(&img)));
567    }
568
569    #[test]
570    fn rejects_non_lisa_dc42() {
571        // Same geometry but tags all zero (no MDDF) → not Lisa.
572        let img = build_image(&[
573            (FID_FREE, 0, vec![]),
574            (FID_FREE, 0, vec![]),
575            (FID_FREE, 0, vec![]),
576        ]);
577        assert!(!looks_like_lisa(&mut Cursor::new(&img)));
578    }
579
580    #[test]
581    fn reads_volume_and_version() {
582        let mut fs = LisaFilesystem::open(Cursor::new(sample_image())).expect("open");
583        assert_eq!(fs.volume_label(), Some("TESTVOL"));
584        assert_eq!(fs.fs_type(), "Lisa FS (flat hash)");
585        assert_eq!(fs.total_size(), 6 * SECTOR_SIZE as u64);
586        assert_eq!(fs.used_size(), 5 * SECTOR_SIZE as u64); // one free block
587                                                            // root lists correctly
588        let root = fs.root().unwrap();
589        assert_eq!(root.path, "/");
590    }
591
592    #[test]
593    fn lists_named_file_and_system_files() {
594        let mut fs = LisaFilesystem::open(Cursor::new(sample_image())).expect("open");
595        let root = fs.root().unwrap();
596        let entries = fs.list_directory(&root).unwrap();
597        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
598        assert!(names.contains(&"HELLO"), "catalog name resolved: {names:?}");
599        assert!(names.contains(&"bootsect-aaaa"));
600        assert!(names.contains(&"MDDF-0001"));
601        assert!(names.contains(&"directory-0004"));
602        // free block is not listed
603        assert!(!names.iter().any(|n| n.starts_with("freeblocks")));
604    }
605
606    #[test]
607    fn reconstructs_file_stripping_label() {
608        let mut fs = LisaFilesystem::open(Cursor::new(sample_image())).expect("open");
609        let root = fs.root().unwrap();
610        let entries = fs.list_directory(&root).unwrap();
611        let hello = entries.iter().find(|e| e.name == "HELLO").unwrap();
612        // Two sectors, first is a normal-file label sector.
613        assert_eq!(hello.size, (2 * SECTOR_SIZE - LABEL_LEN) as u64);
614        let data = fs.read_file(hello, usize::MAX).unwrap();
615        assert!(data.starts_with(b"Hello, Lisa!"), "label stripped");
616        assert!(
617            data.windows(12).any(|w| w == b"SECOND-BLOCK"),
618            "second block present"
619        );
620        // write_file_to matches read_file
621        let mut buf = Vec::new();
622        let n = fs.write_file_to(hello, &mut buf).unwrap();
623        assert_eq!(n as usize, buf.len());
624        assert_eq!(buf, data);
625    }
626
627    #[test]
628    fn system_file_keeps_full_first_sector() {
629        let mut fs = LisaFilesystem::open(Cursor::new(sample_image())).expect("open");
630        let root = fs.root().unwrap();
631        let entries = fs.list_directory(&root).unwrap();
632        let boot = entries.iter().find(|e| e.name == "bootsect-aaaa").unwrap();
633        let data = fs.read_file(boot, usize::MAX).unwrap();
634        assert!(
635            data.starts_with(b"BOOTBLOCK"),
636            "no label strip on system file"
637        );
638        assert_eq!(data.len(), SECTOR_SIZE);
639    }
640
641    #[test]
642    fn btree_volume_falls_back_to_fileid_names() {
643        // fsversion 0x11 and no catalog block → file-XXXX names, extraction ok.
644        let img = build_image(&[
645            (FID_MDDF, 0, mddf_sector(0x11, "Lisa Guide")),
646            (0x0007, 0, file_first_sector(0xEE, b"payload")),
647        ]);
648        let mut fs = LisaFilesystem::open(Cursor::new(img)).expect("open");
649        assert_eq!(fs.fs_type(), "Lisa FS (B-tree)");
650        let root = fs.root().unwrap();
651        let entries = fs.list_directory(&root).unwrap();
652        let f = entries.iter().find(|e| e.name == "file-0007").unwrap();
653        assert!(f.path.contains("[0007]"));
654        let data = fs.read_file(f, usize::MAX).unwrap();
655        assert!(data.starts_with(b"payload"));
656    }
657}