1#[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;
39const LABEL_LEN: usize = 0xF0;
42const CATALOG_ENTRY: usize = 54;
44const CATALOG_FILEID_OFF: usize = 36;
46
47const 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
62struct LisaEntry {
64 fileid: u16,
65 name: String,
66 sectors: Vec<usize>,
68 strip_label: bool,
70 size: u64,
72}
73
74pub struct LisaFilesystem {
76 numblocks: usize,
77 data: Vec<u8>,
78 tags: Vec<u8>,
79 volume_name: String,
80 fs_version_name: &'static str,
81 entries: Vec<LisaEntry>,
83 free_blocks: usize,
84}
85
86fn 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
100fn 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
116fn is_normal_file(fileid: u16) -> bool {
120 fileid > FID_DIRECTORY && fileid < FID_ERASED
121}
122
123impl LisaFilesystem {
124 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 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 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 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 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 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 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 self.entries.sort_by_key(|e| e.fileid);
267 Ok(())
268 }
269
270 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 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
336fn 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
349fn 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
362pub 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 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 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 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 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; 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 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 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 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()), (FID_MDDF, 0, mddf_sector(0x0f, "TESTVOL")), (FID_DIRECTORY, 0, catalog_sector("HELLO", 0x0005)), (0x0005, 0, file_first_sector(0xEE, b"Hello, Lisa!")), (0x0005, 1, b"SECOND-BLOCK".to_vec()), (FID_FREE, 0, vec![]), ])
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 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); 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 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 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 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 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}