Skip to main content

fstool/fs/affs/
mod.rs

1//! Amiga Fast/Old File System (AFFS) — read + write support.
2//!
3//! Reads Amiga DOS volumes (`.adf` floppy images and larger hard-file
4//! volumes) in both their OFS ("Old File System") and FFS ("Fast File
5//! System") variants, including the International and (transparently
6//! ignored) directory-cache flavours.
7//!
8//! ## On-disk format (big-endian, 512-byte blocks)
9//!
10//! Block 0–1 hold the *boot block*: bytes `"DOS"` then a flag byte whose
11//! low three bits select the variant (`DOS\0`..`DOS\7`): bit0 FFS/OFS, bit1
12//! International name hashing, bit2 directory cache. The *root block* sits at
13//! the middle of the volume and carries an `ADF_HT_SIZE`-entry name hash
14//! table plus the volume bitmap pointers and name. Directories are the same
15//! shape (a hash table of entries); files chain through a *file header* (and
16//! optional *file-extension* blocks) listing their data blocks. Under OFS
17//! every data block carries a 24-byte header (and only `512-24` payload
18//! bytes); under FFS data blocks are raw.
19//!
20//! Layout offsets follow adflib's `adf_blk.h` structs; the reader is
21//! cross-checked against the Linux kernel `affs` driver.
22//!
23//! Writing goes through the `writer` submodule: [`Affs::format`] generates a
24//! fresh volume and [`Affs::open_writable`] loads an existing one into a tree
25//! that `add`/`mkdir`/`rm` mutate and `flush` re-serialises block-by-block.
26//! A read-only handle ([`Affs::open`]) reports
27//! [`MutationCapability::Immutable`]; a writable one reports `Mutable`.
28
29use std::collections::HashMap;
30use std::io::{self, Read, Seek, SeekFrom};
31use std::path::Path;
32
33use crate::block::BlockDevice;
34use crate::fs::{DirEntry, EntryKind, Filesystem, MutationCapability};
35use crate::{Error, Result};
36
37mod editor;
38mod size_plan;
39mod writer;
40pub use size_plan::AffsSizePlan;
41pub use writer::AffsFormatOpts;
42
43/// How a writable `Affs` handle persists changes.
44enum Write {
45    /// Read-only handle.
46    None,
47    /// Fresh volume built from scratch — the in-memory model is authoritative
48    /// and the whole image is serialised on flush.
49    Format(writer::AffsWriter),
50    /// Existing volume edited incrementally on disk; reads come from the
51    /// (disk-derived) `children` index, refreshed after each mutation.
52    InPlace(editor::AffsEditor),
53}
54
55/// Logical block size of a bare Amiga DOS volume.
56const BSIZE: usize = 512;
57/// Name hash-table entry count for a 512-byte block (`BSIZE/4 - 56`).
58const HT_SIZE: usize = 72;
59/// Data-block pointer slots in a file header / extension block.
60const MAX_DATABLK: usize = 72;
61/// Longest Amiga file / directory / volume name.
62const MAX_NAME_LEN: usize = 30;
63
64// Primary block types (word @0).
65const T_HEADER: i32 = 2;
66const T_LIST: i32 = 16;
67/// OFS data-block type (used when building/validating OFS data blocks).
68const T_DATA: i32 = 8;
69
70// Secondary types (word @ BSIZE-4 = 0x1fc).
71const ST_ROOT: i32 = 1;
72const ST_USERDIR: i32 = 2;
73const ST_SOFTLINK: i32 = 3;
74const ST_LINKDIR: i32 = 4;
75const ST_FILE: i32 = -3;
76const ST_LINKFILE: i32 = -4;
77
78// Field offsets shared by header/dir/file/ext blocks.
79const OFF_TYPE: usize = 0x000;
80const OFF_HIGH_SEQ: usize = 0x008;
81const OFF_HASHTABLE: usize = 0x018;
82const OFF_BYTE_SIZE: usize = 0x144; // file header
83const OFF_DAYS: usize = 0x1a4; // last-modified date triplet (dir/file)
84const OFF_NAME_LEN: usize = 0x1b0;
85const OFF_NEXT_SAME_HASH: usize = 0x1f0;
86const OFF_EXTENSION: usize = 0x1f8; // file header / ext: next extension block
87const OFF_SEC_TYPE: usize = 0x1fc;
88
89/// Seconds between the Unix epoch (1970-01-01) and the Amiga epoch
90/// (1978-01-01): 2922 days (8 years incl. leap days 1972, 1976).
91const AMIGA_EPOCH: u64 = 2922 * 86_400;
92
93#[inline]
94fn be_u32(b: &[u8], off: usize) -> u32 {
95    u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
96}
97
98#[inline]
99fn be_i32(b: &[u8], off: usize) -> i32 {
100    be_u32(b, off) as i32
101}
102
103/// An Amiga "normal" block checksum is valid when every 32-bit word
104/// (including the stored checksum) sums to zero with wrapping.
105fn block_checksum_ok(b: &[u8]) -> bool {
106    let mut sum = 0u32;
107    let mut i = 0;
108    while i < BSIZE {
109        sum = sum.wrapping_add(be_u32(b, i));
110        i += 4;
111    }
112    sum == 0
113}
114
115/// Amiga `(days, minutes, ticks)` → seconds since the Unix epoch. Ticks run
116/// at 1/50 s. Negative/garbage dates clamp to 0.
117fn amiga_date_to_unix(days: i32, mins: i32, ticks: i32) -> u32 {
118    if days < 0 || mins < 0 || ticks < 0 {
119        return 0;
120    }
121    let secs = AMIGA_EPOCH + days as u64 * 86_400 + mins as u64 * 60 + ticks as u64 / 50;
122    secs.try_into().unwrap_or(u32::MAX)
123}
124
125/// Decode a BCPL name (length byte at `OFF_NAME_LEN`, chars following) as
126/// ISO-8859-1 (Latin-1, the Amiga character set).
127fn read_name(block: &[u8]) -> String {
128    let len = (block[OFF_NAME_LEN] as usize).min(MAX_NAME_LEN);
129    let start = OFF_NAME_LEN + 1;
130    block[start..start + len]
131        .iter()
132        .map(|&b| b as char)
133        .collect()
134}
135
136/// Variant flags decoded from the boot-block DOS type byte.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub struct Variant {
139    /// Fast File System (raw data blocks) when set; OFS (24-byte data-block
140    /// headers) when clear.
141    pub ffs: bool,
142    /// International case-insensitive name hashing.
143    pub intl: bool,
144    /// Directory-cache mode (read transparently via the hash table).
145    pub dircache: bool,
146}
147
148impl Variant {
149    fn from_flag(flag: u8) -> Self {
150        Self {
151            ffs: flag & 1 != 0,
152            intl: flag & 2 != 0,
153            dircache: flag & 4 != 0,
154        }
155    }
156
157    /// The `DOS\n` label for this variant.
158    pub fn dos_label(&self) -> String {
159        let n = (self.ffs as u8) | (self.intl as u8) << 1 | (self.dircache as u8) << 2;
160        format!("DOS\\{n}")
161    }
162}
163
164/// One directory entry resolved into the read index.
165#[derive(Clone, Debug)]
166struct Node {
167    name: String,
168    /// Header block number (the file/dir header on disk).
169    block: u32,
170    kind: EntryKind,
171    /// File size in bytes (0 for non-files).
172    size: u64,
173    /// Symlink target (Latin-1 decoded from the header data area).
174    link_target: Option<String>,
175    mtime: u32,
176}
177
178/// An Amiga OFS/FFS volume, opened read-only.
179pub struct Affs {
180    block_size: u32,
181    root_block: u32,
182    variant: Variant,
183    /// Volume name (root block BCPL name, Latin-1).
184    pub volume_name: String,
185    /// Parent header-block → its children. Root's children are keyed by
186    /// `root_block`. Used for read-only (writer-absent) access.
187    children: HashMap<u32, Vec<Node>>,
188    /// How writes are persisted (read-only / rebuild-on-flush / in-place).
189    mode: Write,
190}
191
192impl Affs {
193    /// Open and index an Amiga DOS volume.
194    pub fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
195        let total = dev.total_size();
196        let block_size = BSIZE as u32;
197        if total < (BSIZE as u64) * 4 {
198            return Err(Error::InvalidImage("affs: image too small".into()));
199        }
200
201        // Boot block: "DOS" + flag byte.
202        let mut boot = [0u8; 12];
203        dev.read_at(0, &mut boot)?;
204        if &boot[0..3] != b"DOS" || boot[3] > 7 {
205            return Err(Error::InvalidImage(
206                "affs: missing DOS boot signature".into(),
207            ));
208        }
209        let variant = Variant::from_flag(boot[3]);
210
211        let total_blocks = (total / BSIZE as u64) as u32;
212        // Root block is the middle of the volume; trust the boot pointer only
213        // when it actually lands on a valid root block.
214        let boot_root = be_u32(&boot, 8);
215        let candidates = [boot_root, total_blocks / 2, (total_blocks - 1) / 2];
216        let mut root_block = 0u32;
217        let mut root = vec![0u8; BSIZE];
218        for &cand in &candidates {
219            if cand == 0 || cand as u64 >= total_blocks as u64 {
220                continue;
221            }
222            dev.read_at(cand as u64 * BSIZE as u64, &mut root)?;
223            if be_i32(&root, OFF_TYPE) == T_HEADER
224                && be_i32(&root, OFF_SEC_TYPE) == ST_ROOT
225                && block_checksum_ok(&root)
226            {
227                root_block = cand;
228                break;
229            }
230        }
231        if root_block == 0 {
232            return Err(Error::InvalidImage(
233                "affs: no valid root block found".into(),
234            ));
235        }
236
237        let volume_name = read_name(&root);
238
239        let mut affs = Self {
240            block_size,
241            root_block,
242            variant,
243            volume_name,
244            children: HashMap::new(),
245            mode: Write::None,
246        };
247        affs.build_index(dev, total_blocks)?;
248        Ok(affs)
249    }
250
251    /// Format a fresh volume on `dev` and return a writable handle. Backed by
252    /// the rebuild-on-flush serialiser (the right tool for a brand-new image).
253    pub fn format(dev: &mut dyn BlockDevice, opts: &AffsFormatOpts) -> Result<Self> {
254        let w = writer::AffsWriter::format(dev, opts)?;
255        Ok(Self {
256            block_size: BSIZE as u32,
257            root_block: 0,
258            variant: w.variant(),
259            volume_name: w.volume_name().to_string(),
260            children: HashMap::new(),
261            mode: Write::Format(w),
262        })
263    }
264
265    /// Open an existing volume for **in-place** mutation. Only the bitmap is
266    /// loaded; file contents are never read into RAM. `add`/`mkdir`/`rm` edit
267    /// the affected blocks directly and `flush` writes back the bitmap — the
268    /// rest of the image stays byte-for-byte unchanged.
269    pub fn open_writable(dev: &mut dyn BlockDevice) -> Result<Self> {
270        let mut affs = Self::open(dev)?;
271        let total_blocks = (dev.total_size() / BSIZE as u64) as u32;
272        let ed = editor::AffsEditor::open(dev, total_blocks, affs.variant, affs.root_block)?;
273        affs.mode = Write::InPlace(ed);
274        Ok(affs)
275    }
276
277    /// Re-read the directory tree from disk into `children` after an in-place
278    /// edit. Only metadata is walked — file contents are never read.
279    fn refresh_index(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
280        let total_blocks = (dev.total_size() / BSIZE as u64) as u32;
281        self.build_index(dev, total_blocks)
282    }
283
284    /// Split `…/name` into its parent directory's on-disk header block and the
285    /// final component, resolving the parent against `children` and rejecting a
286    /// duplicate name. The returned `&str` borrows `path`, not `self`.
287    fn parent_block_and_name<'p>(&self, path: &'p str) -> Result<(u32, &'p str)> {
288        let trimmed = path.trim_matches('/');
289        let (dir, name) = trimmed.rsplit_once('/').unwrap_or(("", trimmed));
290        if name.is_empty() {
291            return Err(Error::InvalidArgument("affs: empty entry name".into()));
292        }
293        let parent = match self.resolve(dir) {
294            Some(Resolved::Dir(b)) => b,
295            Some(_) => {
296                return Err(Error::InvalidArgument(
297                    "affs: parent is not a directory".into(),
298                ));
299            }
300            None => {
301                return Err(Error::InvalidArgument(format!(
302                    "affs: no such directory {dir:?}"
303                )));
304            }
305        };
306        if self
307            .children
308            .get(&parent)
309            .is_some_and(|kids| kids.iter().any(|n| n.name.eq_ignore_ascii_case(name)))
310        {
311            return Err(Error::InvalidArgument(format!(
312                "affs: {name:?} already exists"
313            )));
314        }
315        Ok((parent, name))
316    }
317
318    /// Resolve a path to `(parent_block, entry_block, name)` for removal.
319    fn locate_for_remove<'p>(&self, path: &'p str) -> Result<(u32, u32, &'p str)> {
320        let trimmed = path.trim_matches('/');
321        let (dir, name) = trimmed.rsplit_once('/').unwrap_or(("", trimmed));
322        if name.is_empty() {
323            return Err(Error::InvalidArgument(
324                "affs: cannot remove the root".into(),
325            ));
326        }
327        let parent = match self.resolve(dir) {
328            Some(Resolved::Dir(b)) => b,
329            _ => {
330                return Err(Error::InvalidArgument(format!(
331                    "affs: no such directory {dir:?}"
332                )));
333            }
334        };
335        let entry = self
336            .children
337            .get(&parent)
338            .and_then(|kids| kids.iter().find(|n| n.name.eq_ignore_ascii_case(name)))
339            .map(|n| n.block)
340            .ok_or_else(|| Error::InvalidArgument(format!("affs: no such path {path:?}")))?;
341        Ok((parent, entry, name))
342    }
343
344    /// Walk the directory tree from the root, populating `children`.
345    fn build_index(&mut self, dev: &mut dyn BlockDevice, total_blocks: u32) -> Result<()> {
346        let mut children: HashMap<u32, Vec<Node>> = HashMap::new();
347        // Stack of directory header blocks still to expand.
348        let mut stack = vec![self.root_block];
349        let mut visited = std::collections::HashSet::new();
350        while let Some(dir_block) = stack.pop() {
351            if !visited.insert(dir_block) {
352                continue;
353            }
354            let mut block = vec![0u8; BSIZE];
355            dev.read_at(dir_block as u64 * BSIZE as u64, &mut block)?;
356            let entries = self.read_hashtable(dev, &block, total_blocks, &visited)?;
357            for node in &entries {
358                if node.kind == EntryKind::Dir {
359                    stack.push(node.block);
360                }
361            }
362            children.insert(dir_block, entries);
363        }
364        self.children = children;
365        Ok(())
366    }
367
368    /// Read every entry hanging off a directory/root block's hash table,
369    /// following each bucket's same-hash chain.
370    fn read_hashtable(
371        &self,
372        dev: &mut dyn BlockDevice,
373        dir: &[u8],
374        total_blocks: u32,
375        visited: &std::collections::HashSet<u32>,
376    ) -> Result<Vec<Node>> {
377        let mut out = Vec::new();
378        for i in 0..HT_SIZE {
379            let mut entry = be_u32(dir, OFF_HASHTABLE + i * 4);
380            // Per-bucket set of header blocks already walked on this
381            // same-hash chain. The directory-level `visited` set tracks
382            // directories being indexed, not sibling file headers, so a
383            // cyclic `nextSameHash` pointer would otherwise re-push the same
384            // nodes forever and exhaust memory. Break the moment a header
385            // repeats within this chain.
386            let mut seen = std::collections::HashSet::new();
387            while entry != 0 && (entry as u64) < total_blocks as u64 {
388                if visited.contains(&entry) {
389                    break; // already-seen header → avoid cycles
390                }
391                if !seen.insert(entry) {
392                    break; // cyclic same-hash chain
393                }
394                let mut hb = vec![0u8; BSIZE];
395                dev.read_at(entry as u64 * BSIZE as u64, &mut hb)?;
396                let sec_type = be_i32(&hb, OFF_SEC_TYPE);
397                let kind = match sec_type {
398                    ST_USERDIR | ST_LINKDIR => EntryKind::Dir,
399                    ST_FILE | ST_LINKFILE => EntryKind::Regular,
400                    ST_SOFTLINK => EntryKind::Symlink,
401                    _ => EntryKind::Unknown,
402                };
403                let name = read_name(&hb);
404                let size = if kind == EntryKind::Regular {
405                    be_u32(&hb, OFF_BYTE_SIZE) as u64
406                } else {
407                    0
408                };
409                let link_target = if kind == EntryKind::Symlink {
410                    Some(read_softlink_target(&hb))
411                } else {
412                    None
413                };
414                let mtime = amiga_date_to_unix(
415                    be_i32(&hb, OFF_DAYS),
416                    be_i32(&hb, OFF_DAYS + 4),
417                    be_i32(&hb, OFF_DAYS + 8),
418                );
419                if !name.is_empty() && kind != EntryKind::Unknown {
420                    out.push(Node {
421                        name,
422                        block: entry,
423                        kind,
424                        size,
425                        link_target,
426                        mtime,
427                    });
428                }
429                entry = be_u32(&hb, OFF_NEXT_SAME_HASH);
430            }
431        }
432        Ok(out)
433    }
434
435    /// Resolve a `/`-separated path to its node (or the root directory).
436    fn resolve<'a>(&'a self, path: &str) -> Option<Resolved<'a>> {
437        let trimmed = path.trim_matches('/');
438        if trimmed.is_empty() {
439            return Some(Resolved::Dir(self.root_block));
440        }
441        let mut cur_dir = self.root_block;
442        let mut comps = trimmed.split('/').peekable();
443        while let Some(comp) = comps.next() {
444            let kids = self.children.get(&cur_dir)?;
445            let node = kids.iter().find(|n| n.name.eq_ignore_ascii_case(comp))?;
446            let is_last = comps.peek().is_none();
447            match node.kind {
448                EntryKind::Dir => {
449                    if is_last {
450                        return Some(Resolved::Dir(node.block));
451                    }
452                    cur_dir = node.block;
453                }
454                _ => {
455                    if is_last {
456                        return Some(Resolved::Node(node));
457                    }
458                    return None; // path descends into a non-directory
459                }
460            }
461        }
462        None
463    }
464
465    /// List a directory path. In `Format` mode the in-memory model is
466    /// authoritative; otherwise the on-disk-derived `children` index is (kept
467    /// fresh after each in-place edit).
468    pub fn list_path(&self, path: &str) -> Result<Vec<DirEntry>> {
469        if let Write::Format(w) = &self.mode {
470            return w.list(path);
471        }
472        let block = match self.resolve(path) {
473            Some(Resolved::Dir(b)) => b,
474            Some(_) => {
475                return Err(Error::InvalidArgument("affs: not a directory".into()));
476            }
477            None => {
478                return Err(Error::InvalidArgument(format!(
479                    "affs: no such path {path:?}"
480                )));
481            }
482        };
483        let mut out = Vec::new();
484        if let Some(kids) = self.children.get(&block) {
485            for n in kids {
486                out.push(DirEntry {
487                    name: n.name.clone(),
488                    inode: n.block,
489                    kind: n.kind,
490                    size: n.size,
491                });
492            }
493        }
494        Ok(out)
495    }
496
497    /// Build the ordered list of data-block numbers backing a file, walking
498    /// the file header plus any extension blocks.
499    fn data_blocks(&self, dev: &mut dyn BlockDevice, header: u32) -> Result<Vec<u32>> {
500        // The extension-block chain is attacker-controlled: a self-referential
501        // or out-of-range `OFF_EXTENSION` pointer would otherwise spin until
502        // the `u32::MAX/2` guard, accumulating data-block pointers each pass
503        // and exhausting memory. Bound the walk by the volume's block count
504        // and break the moment we revisit an extension block.
505        let total_blocks = (dev.total_size() / BSIZE as u64) as u32;
506        let mut blocks = Vec::new();
507        let mut cur = header;
508        let mut visited = std::collections::HashSet::new();
509        let mut buf = vec![0u8; BSIZE];
510        while cur != 0 {
511            if (cur as u64) >= total_blocks as u64 {
512                return Err(Error::InvalidImage(
513                    "affs: file extension block out of range".into(),
514                ));
515            }
516            if !visited.insert(cur) {
517                return Err(Error::InvalidImage("affs: file extension loop".into()));
518            }
519            dev.read_at(cur as u64 * BSIZE as u64, &mut buf)?;
520            let high_seq = be_i32(&buf, OFF_HIGH_SEQ).clamp(0, MAX_DATABLK as i32) as usize;
521            // Data-block pointers are stored in reverse: the first data block
522            // is at slot MAX_DATABLK-1, descending toward slot 0.
523            for i in 0..high_seq {
524                let slot = MAX_DATABLK - 1 - i;
525                let ptr = be_u32(&buf, OFF_HASHTABLE + slot * 4);
526                if ptr != 0 {
527                    blocks.push(ptr);
528                }
529            }
530            cur = be_u32(&buf, OFF_EXTENSION);
531        }
532        Ok(blocks)
533    }
534
535    /// Open a regular file for streaming reads. The returned reader owns its
536    /// resolved block list (or, for a mutable handle, the in-memory bytes), so
537    /// it borrows only `dev` (not `self`). When a writer is attached its model
538    /// is authoritative, so pending writes are readable before flush.
539    pub fn open_file_reader<'a>(
540        &self,
541        dev: &'a mut dyn BlockDevice,
542        path: &str,
543    ) -> Result<AffsFileReader<'a>> {
544        if let Write::Format(w) = &self.mode {
545            let data = w.read(path)?;
546            let size = data.len() as u64;
547            return Ok(AffsFileReader {
548                dev,
549                blocks: Vec::new(),
550                block_size: self.block_size,
551                ofs: false,
552                size,
553                pos: 0,
554                mem: Some(data),
555            });
556        }
557        let (header, size) = match self.resolve(path) {
558            Some(Resolved::Node(n)) if n.kind == EntryKind::Regular => (n.block, n.size),
559            Some(_) => return Err(Error::InvalidArgument("affs: not a regular file".into())),
560            None => {
561                return Err(Error::InvalidArgument(format!(
562                    "affs: no such path {path:?}"
563                )));
564            }
565        };
566        let blocks = self.data_blocks(dev, header)?;
567        Ok(AffsFileReader {
568            dev,
569            blocks,
570            block_size: self.block_size,
571            ofs: !self.variant.ffs,
572            size,
573            pos: 0,
574            mem: None,
575        })
576    }
577
578    /// The volume's variant flags.
579    pub fn variant(&self) -> Variant {
580        self.variant
581    }
582}
583
584enum Resolved<'a> {
585    Dir(u32),
586    Node(&'a Node),
587}
588
589/// Read the soft-link target stored in a softlink header's data area
590/// (`OFF_HASHTABLE` onward, NUL-terminated, Latin-1).
591fn read_softlink_target(hb: &[u8]) -> String {
592    let start = OFF_HASHTABLE;
593    let end = (start..BSIZE).find(|&i| hb[i] == 0).unwrap_or(BSIZE);
594    hb[start..end].iter().map(|&b| b as char).collect()
595}
596
597/// Streaming reader over a file's data blocks. Honours the OFS 24-byte
598/// per-block header (488 payload bytes) vs FFS raw 512-byte blocks; file
599/// length bounds the final block.
600pub struct AffsFileReader<'a> {
601    dev: &'a mut dyn BlockDevice,
602    blocks: Vec<u32>,
603    block_size: u32,
604    ofs: bool,
605    size: u64,
606    pos: u64,
607    /// When `Some`, the file's bytes are served from memory (a writable
608    /// handle whose contents aren't yet at a known on-disk location).
609    mem: Option<Vec<u8>>,
610}
611
612impl AffsFileReader<'_> {
613    /// Payload bytes carried by each data block under this variant.
614    fn payload(&self) -> u64 {
615        if self.ofs {
616            self.block_size as u64 - 24
617        } else {
618            self.block_size as u64
619        }
620    }
621}
622
623impl Read for AffsFileReader<'_> {
624    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
625        if self.pos >= self.size {
626            return Ok(0);
627        }
628        if let Some(m) = &self.mem {
629            let start = self.pos as usize;
630            let want = (buf.len()).min(m.len() - start);
631            buf[..want].copy_from_slice(&m[start..start + want]);
632            self.pos += want as u64;
633            return Ok(want);
634        }
635        let payload = self.payload();
636        let blk_idx = (self.pos / payload) as usize;
637        let within = self.pos % payload;
638        let Some(&blk) = self.blocks.get(blk_idx) else {
639            return Ok(0);
640        };
641        let data_off = if self.ofs { 24 } else { 0 };
642        let avail_in_block = (payload - within).min(self.size - self.pos);
643        let want = (buf.len() as u64).min(avail_in_block) as usize;
644        let off = blk as u64 * self.block_size as u64 + data_off + within;
645        self.dev
646            .read_at(off, &mut buf[..want])
647            .map_err(|e| io::Error::other(e.to_string()))?;
648        self.pos += want as u64;
649        Ok(want)
650    }
651}
652
653impl Seek for AffsFileReader<'_> {
654    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
655        let new = match pos {
656            SeekFrom::Start(n) => n as i64,
657            SeekFrom::End(n) => self.size as i64 + n,
658            SeekFrom::Current(n) => self.pos as i64 + n,
659        };
660        if new < 0 {
661            return Err(io::Error::new(
662                io::ErrorKind::InvalidInput,
663                "affs: seek before start",
664            ));
665        }
666        self.pos = new as u64;
667        Ok(self.pos)
668    }
669}
670
671impl crate::fs::FileReadHandle for AffsFileReader<'_> {
672    fn len(&self) -> u64 {
673        self.size
674    }
675}
676
677impl crate::fs::FilesystemFactory for Affs {
678    type FormatOpts = AffsFormatOpts;
679
680    fn format(dev: &mut dyn BlockDevice, opts: &Self::FormatOpts) -> Result<Self> {
681        Affs::format(dev, opts)
682    }
683
684    fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
685        Affs::open(dev)
686    }
687
688    fn size_plan(opts: &Self::FormatOpts) -> Option<Box<dyn crate::fs::FsSizePlan>> {
689        Some(Box::new(AffsSizePlan::new(opts.ffs)))
690    }
691}
692
693impl Filesystem for Affs {
694    fn streams_immediately(&self) -> bool {
695        // create_file reads its source into memory synchronously.
696        true
697    }
698
699    fn create_file(
700        &mut self,
701        dev: &mut dyn BlockDevice,
702        path: &Path,
703        src: crate::fs::FileSource,
704        meta: crate::fs::FileMeta,
705    ) -> Result<()> {
706        let s = path
707            .to_str()
708            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
709        if matches!(self.mode, Write::None) {
710            return Err(Error::Immutable {
711                kind: "affs",
712                op: "add",
713            });
714        }
715        let (mut reader, len) = src.open()?;
716        let mut data = Vec::with_capacity(len as usize);
717        std::io::Read::take(&mut reader, len).read_to_end(&mut data)?;
718        if let Write::Format(w) = &mut self.mode {
719            w.insert_file(s, data, meta.mtime)?;
720            return Ok(());
721        }
722        // In-place: write the file's blocks directly, link into the parent.
723        let (parent, name) = self.parent_block_and_name(s)?;
724        if let Write::InPlace(ed) = &mut self.mode {
725            ed.create_file(dev, parent, name, &data, meta.mtime)?;
726        }
727        self.refresh_index(dev)
728    }
729
730    fn create_dir(
731        &mut self,
732        dev: &mut dyn BlockDevice,
733        path: &Path,
734        meta: crate::fs::FileMeta,
735    ) -> Result<()> {
736        let s = path
737            .to_str()
738            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
739        if matches!(self.mode, Write::None) {
740            return Err(Error::Immutable {
741                kind: "affs",
742                op: "mkdir",
743            });
744        }
745        if let Write::Format(w) = &mut self.mode {
746            w.insert_dir(s, meta.mtime)?;
747            return Ok(());
748        }
749        let (parent, name) = self.parent_block_and_name(s)?;
750        if let Write::InPlace(ed) = &mut self.mode {
751            ed.create_dir(dev, parent, name, meta.mtime)?;
752        }
753        self.refresh_index(dev)
754    }
755
756    fn create_symlink(
757        &mut self,
758        _dev: &mut dyn BlockDevice,
759        _path: &Path,
760        _target: &Path,
761        _meta: crate::fs::FileMeta,
762    ) -> Result<()> {
763        // Amiga soft links exist (ST_SOFTLINK) but are deferred; the repack
764        // sink treats this as a skippable entry.
765        Err(Error::Unsupported(
766            "affs: symlink creation not yet implemented".into(),
767        ))
768    }
769
770    fn create_device(
771        &mut self,
772        _dev: &mut dyn BlockDevice,
773        _path: &Path,
774        _kind: crate::fs::DeviceKind,
775        _major: u32,
776        _minor: u32,
777        _meta: crate::fs::FileMeta,
778    ) -> Result<()> {
779        Err(Error::Immutable {
780            kind: "affs",
781            op: "mknod",
782        })
783    }
784
785    fn remove(&mut self, dev: &mut dyn BlockDevice, path: &Path) -> Result<()> {
786        let s = path
787            .to_str()
788            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
789        if matches!(self.mode, Write::None) {
790            return Err(Error::Immutable {
791                kind: "affs",
792                op: "rm",
793            });
794        }
795        if let Write::Format(w) = &mut self.mode {
796            return w.remove(s);
797        }
798        let (parent, entry, name) = self.locate_for_remove(s)?;
799        if let Write::InPlace(ed) = &mut self.mode {
800            ed.remove(dev, parent, entry, name)?;
801        }
802        self.refresh_index(dev)
803    }
804
805    fn list(&mut self, _dev: &mut dyn BlockDevice, path: &Path) -> Result<Vec<DirEntry>> {
806        let s = path
807            .to_str()
808            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
809        self.list_path(s)
810    }
811
812    fn getattr(&mut self, _dev: &mut dyn BlockDevice, path: &Path) -> Result<crate::fs::FileAttrs> {
813        let s = path
814            .to_str()
815            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
816        if let Write::Format(w) = &self.mode {
817            return w.getattr(s);
818        }
819        let (kind, size, mtime, inode) = match self.resolve(s) {
820            Some(Resolved::Dir(b)) => (EntryKind::Dir, 0u64, 0u32, b),
821            Some(Resolved::Node(n)) => (n.kind, n.size, n.mtime, n.block),
822            None => return Err(Error::InvalidArgument(format!("affs: no such path {s:?}"))),
823        };
824        let mode = match kind {
825            EntryKind::Dir => 0o755,
826            EntryKind::Symlink => 0o777,
827            _ => 0o644,
828        };
829        Ok(crate::fs::FileAttrs {
830            kind,
831            mode,
832            uid: 0,
833            gid: 0,
834            size,
835            blocks: size.div_ceil(512),
836            nlink: if kind == EntryKind::Dir { 2 } else { 1 },
837            atime: mtime,
838            mtime,
839            ctime: mtime,
840            rdev: 0,
841            inode,
842        })
843    }
844
845    fn flush(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
846        match &mut self.mode {
847            Write::Format(w) => w.flush(dev)?,
848            Write::InPlace(ed) => ed.flush(dev)?,
849            Write::None => {}
850        }
851        Ok(())
852    }
853
854    fn read_file<'a>(
855        &'a mut self,
856        dev: &'a mut dyn BlockDevice,
857        path: &Path,
858    ) -> Result<Box<dyn Read + 'a>> {
859        let s = path
860            .to_str()
861            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
862        Ok(Box::new(self.open_file_reader(dev, s)?))
863    }
864
865    fn open_file_ro<'a>(
866        &'a mut self,
867        dev: &'a mut dyn BlockDevice,
868        path: &Path,
869    ) -> Result<Box<dyn crate::fs::FileReadHandle + 'a>> {
870        let s = path
871            .to_str()
872            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
873        Ok(Box::new(self.open_file_reader(dev, s)?))
874    }
875
876    fn read_symlink(
877        &mut self,
878        _dev: &mut dyn BlockDevice,
879        path: &Path,
880    ) -> Result<std::path::PathBuf> {
881        let s = path
882            .to_str()
883            .ok_or_else(|| Error::InvalidArgument("affs: non-UTF-8 path".into()))?;
884        match self.resolve(s) {
885            Some(Resolved::Node(n)) if n.kind == EntryKind::Symlink => Ok(
886                std::path::PathBuf::from(n.link_target.clone().unwrap_or_default()),
887            ),
888            Some(_) => Err(Error::InvalidArgument("affs: not a symlink".into())),
889            None => Err(Error::InvalidArgument(format!("affs: no such path {s:?}"))),
890        }
891    }
892
893    fn mutation_capability(&self) -> MutationCapability {
894        if matches!(self.mode, Write::Format(_) | Write::InPlace(_)) {
895            MutationCapability::Mutable
896        } else {
897            MutationCapability::Immutable
898        }
899    }
900}
901
902#[allow(dead_code)]
903const _: () = {
904    // Compile-time sanity: the constants line up with a 512-byte block.
905    assert!(HT_SIZE == BSIZE / 4 - 56);
906    assert!(OFF_SEC_TYPE == BSIZE - 4);
907    assert!(OFF_EXTENSION == BSIZE - 8);
908    assert!(OFF_NEXT_SAME_HASH == BSIZE - 16);
909    assert!(T_LIST == 16 && T_HEADER == 2);
910};
911
912#[cfg(test)]
913mod tests;