Skip to main content

disk_core/
filesystem.rs

1use std::fmt;
2use std::io::Write;
3use std::path::PathBuf;
4
5use super::entry::FileEntry;
6
7/// Trait for browsing a filesystem within a partition.
8pub trait Filesystem: Send {
9    /// Get the root directory entry.
10    fn root(&mut self) -> Result<FileEntry, FilesystemError>;
11
12    /// List the contents of a directory.
13    fn list_directory(&mut self, entry: &FileEntry) -> Result<Vec<FileEntry>, FilesystemError>;
14
15    /// Read file contents (up to `max_bytes`).
16    fn read_file(
17        &mut self,
18        entry: &FileEntry,
19        max_bytes: usize,
20    ) -> Result<Vec<u8>, FilesystemError>;
21
22    /// Volume label, if available.
23    fn volume_label(&self) -> Option<&str>;
24
25    /// Filesystem type name (e.g., "FAT16", "FAT32").
26    fn fs_type(&self) -> &str;
27
28    /// Total filesystem size in bytes.
29    fn total_size(&self) -> u64;
30
31    /// Used space in bytes.
32    fn used_size(&self) -> u64;
33
34    /// Allocation unit (cluster / block) size in bytes, when the
35    /// filesystem has a single fixed one. The cross-image copy preflight
36    /// uses this to project real on-disk consumption — many small files
37    /// each round up to one unit, so summing raw byte sizes badly
38    /// underestimates usage on large-cluster volumes. `None` means
39    /// "unknown"; the copy engine then falls back to a 512-byte floor.
40    fn allocation_unit(&self) -> Option<u64> {
41        None
42    }
43
44    /// Returns the minimum number of bytes from the partition start needed to
45    /// capture all filesystem data **without moving any data on disk** — i.e.
46    /// the position of the last allocated byte. Used for smart backup trimming.
47    ///
48    /// Default implementation returns `total_size()` (no trimming).
49    fn last_data_byte(&mut self) -> Result<u64, FilesystemError> {
50        Ok(self.total_size())
51    }
52
53    /// Returns the minimum partition size that could host all live filesystem
54    /// data **after a defragmenting clone** (data packed from offset 0).
55    ///
56    /// For most filesystems this is identical to `last_data_byte()` — there is
57    /// no clone-shrink path. HFS+ overrides this because a fragmented volume's
58    /// in-place trim point hugs the partition tail even when most blocks are
59    /// free. See `docs/hfsplus_enhancements.md` Phase 1.
60    ///
61    /// Callers should treat the value as an approximate target: the actual
62    /// clone may need a small additional margin for fresh B-tree headers and
63    /// alignment.
64    fn defragmented_minimum_size(&mut self) -> Result<u64, FilesystemError> {
65        self.last_data_byte()
66    }
67
68    /// Returns per-fork fragmentation statistics for this volume, or `None`
69    /// when the filesystem can't compute them (e.g. cluster-allocation
70    /// filesystems whose CompactReader already repacks during the backup
71    /// stream so "fragmentation" doesn't translate into a backup-time
72    /// decision the user can make).
73    ///
74    /// Only HFS+/HFS implement this today; FAT/NTFS/exFAT return `None`
75    /// because the packing CompactReader inherently emits a defragmented
76    /// layout for them, and the UI doesn't surface a defrag toggle on
77    /// those rows.
78    fn fragmentation_stats(&mut self) -> Option<Result<FragmentationStats, FilesystemError>> {
79        None
80    }
81
82    /// Stream file data to a writer. Returns the number of bytes written.
83    ///
84    /// All built-in filesystems (FAT/exFAT/NTFS/ext/btrfs/HFS/HFS+/ProDOS)
85    /// override this with an extent-by-extent implementation that doesn't
86    /// allocate the full file. The default falls back to loading the entire
87    /// file into RAM via `read_file(entry, usize::MAX)`; new filesystems
88    /// should override unless their files are bounded by design.
89    fn write_file_to(
90        &mut self,
91        entry: &FileEntry,
92        writer: &mut dyn Write,
93    ) -> Result<u64, FilesystemError> {
94        let data = self.read_file(entry, usize::MAX)?;
95        writer.write_all(&data)?;
96        Ok(data.len() as u64)
97    }
98
99    /// Write resource fork data to a writer. Returns the number of bytes written.
100    /// Default returns `Ok(0)` (no resource fork).
101    fn write_resource_fork_to(
102        &mut self,
103        _entry: &FileEntry,
104        _writer: &mut dyn Write,
105    ) -> Result<u64, FilesystemError> {
106        Ok(0)
107    }
108
109    /// Returns the resource fork size for a file entry. Default returns `0`.
110    fn resource_fork_size(&mut self, _entry: &FileEntry) -> u64 {
111        0
112    }
113
114    /// Returns the CNID and name of the blessed (bootable) system folder, if set.
115    /// Only meaningful for HFS/HFS+.
116    fn blessed_system_folder(&mut self) -> Option<(u64, String)> {
117        None
118    }
119
120    /// Run filesystem integrity check. Returns `None` if not supported for
121    /// this filesystem type. Override in implementations that support fsck.
122    fn fsck(&mut self) -> Option<Result<super::fsck::FsckResult, FilesystemError>> {
123        None
124    }
125
126    /// List deleted-but-readable entries alongside live ones; `false` when unsupported.
127    fn set_show_deleted(&mut self, _on: bool) -> bool {
128        false
129    }
130
131    /// Decode the journal into per-transaction detail for a history viewer; `Ok(None)` if unjournaled.
132    /// The value is the filesystem's own detail type (HFS+: `JournalDetail`), for the caller to downcast.
133    fn journal_detail(&mut self) -> Result<Option<Box<dyn std::any::Any + Send>>, FilesystemError> {
134        Ok(None)
135    }
136
137    /// Validate that `name` is legal for a new file or directory on this
138    /// filesystem. Returns `Err(InvalidData)` with a human-readable reason
139    /// when the name violates length, character, or encoding rules.
140    ///
141    /// Default implementation accepts any non-empty name; filesystems that
142    /// support editing should override this with their actual rules so that
143    /// GUI callers can validate at staging time rather than at apply time.
144    fn validate_name(&self, name: &str) -> Result<(), FilesystemError> {
145        if name.is_empty() {
146            return Err(FilesystemError::InvalidData("name cannot be empty".into()));
147        }
148        Ok(())
149    }
150
151    /// True when this filesystem reserves `:` as its native path separator
152    /// (classic Mac HFS / HFS+), so `:` can never appear in a catalog name.
153    ///
154    /// When this is `true`, rb-cli accepts an in-image path written with `:`
155    /// separators (e.g. `:System Folder:Apple Menu Items`), which lets a literal
156    /// `/` be an ordinary filename byte — the only way to address a name like
157    /// `Oxyd b/w` unambiguously, since classic-Mac volumes allow `/` in names.
158    ///
159    /// Default `false`: `/` is the only separator, and a literal `/` inside a
160    /// component must be written `\/` (with `\\` for a literal backslash).
161    fn uses_colon_paths(&self) -> bool {
162        false
163    }
164
165    /// Whether name lookup falls back to case-insensitive matching, the way the
166    /// native OS resolves paths on this filesystem; an exact match wins first.
167    fn case_insensitive_lookup(&self) -> bool {
168        folds_case(self.fs_type())
169    }
170
171    /// Whether this filesystem stores POSIX extended attributes (`user.*`,
172    /// `security.*`, …). True on ext / XFS / SquashFS; false on filesystems with
173    /// no xattr concept (Minix, EFS, FAT, HFS). The UI uses this to show or hide
174    /// the xattr section rather than probing every file.
175    fn supports_xattrs(&self) -> bool {
176        false
177    }
178
179    /// Read the extended attributes on `entry`. Default: none. Override on
180    /// xattr-bearing filesystems. Returning an empty list is normal — most files
181    /// carry no xattrs even where the filesystem supports them.
182    fn list_xattrs(
183        &mut self,
184        _entry: &FileEntry,
185    ) -> Result<Vec<super::xattr::Xattr>, FilesystemError> {
186        Ok(Vec::new())
187    }
188}
189
190/// Aggregate fragmentation counts for a volume's user data forks.
191///
192/// `files_with_data` counts forks that have at least one allocated extent;
193/// `fragmented_files` counts forks whose data spans more than one extent
194/// (including the extents-overflow B-tree). The UI derives a percentage
195/// from these two counts.
196#[derive(Debug, Clone, Copy, Default)]
197pub struct FragmentationStats {
198    pub files_with_data: u64,
199    pub fragmented_files: u64,
200}
201
202impl FragmentationStats {
203    pub fn percent(&self) -> Option<f32> {
204        if self.files_with_data == 0 {
205            return None;
206        }
207        Some(100.0 * self.fragmented_files as f32 / self.files_with_data as f32)
208    }
209}
210
211/// Errors from filesystem operations.
212#[derive(Debug)]
213pub enum FilesystemError {
214    Io(std::io::Error),
215    NotADirectory(String),
216    NotFound(String),
217    Parse(String),
218    Unsupported(String),
219    InvalidData(String),
220    AlreadyExists(String),
221    DiskFull(String),
222    XattrTooLarge(String),
223}
224
225impl fmt::Display for FilesystemError {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            FilesystemError::Io(e) => write!(f, "I/O error: {e}"),
229            FilesystemError::NotADirectory(p) => write!(f, "not a directory: {p}"),
230            FilesystemError::NotFound(p) => write!(f, "not found: {p}"),
231            FilesystemError::Parse(msg) => write!(f, "parse error: {msg}"),
232            FilesystemError::Unsupported(msg) => write!(f, "unsupported: {msg}"),
233            FilesystemError::InvalidData(msg) => write!(f, "invalid data: {msg}"),
234            FilesystemError::AlreadyExists(p) => write!(f, "already exists: {p}"),
235            FilesystemError::DiskFull(msg) => write!(f, "disk full: {msg}"),
236            FilesystemError::XattrTooLarge(msg) => write!(f, "extended attribute too large: {msg}"),
237        }
238    }
239}
240
241impl std::error::Error for FilesystemError {}
242
243impl From<std::io::Error> for FilesystemError {
244    fn from(e: std::io::Error) -> Self {
245        FilesystemError::Io(e)
246    }
247}
248
249/// Options for creating a file on an editable filesystem.
250#[derive(Debug, Clone, Default)]
251pub struct CreateFileOptions {
252    /// Unix mode bits (default 0o100666). Ignored on FAT/exFAT.
253    pub mode: Option<u32>,
254    /// Unix user ID (default 0). Ignored on FAT/exFAT/NTFS.
255    pub uid: Option<u32>,
256    /// Unix group ID (default 0). Ignored on FAT/exFAT/NTFS.
257    pub gid: Option<u32>,
258    /// HFS/HFS+ type code (e.g. "TEXT") or ProDOS type as "$XX" (e.g. "$04").
259    /// Auto-detected from extension if not set. This is the caller-typed text
260    /// form; for byte-exact Mac type/creator preservation (high-bit `OSType`s)
261    /// use [`os_type`](CreateFileOptions::os_type) instead.
262    pub type_code: Option<String>,
263    /// HFS/HFS+ creator code (e.g. "MSWD"). Auto-detected from extension if not set.
264    pub creator_code: Option<String>,
265    /// Raw 4-byte Mac `OSType` file type. When set, HFS/HFS+/MFS `create_file`
266    /// writes these bytes into the Finder info verbatim, taking precedence over
267    /// the lossy `type_code` text. Used by fidelity-preserving copies
268    /// (`cp`, `get-binhex`, archive replay) so a high-bit creator like Prince
269    /// of Persia's `PoƒP` (`0xC4`) survives. `None` outside the Mac family.
270    pub os_type: Option<[u8; 4]>,
271    /// Raw 4-byte Mac `OSType` creator. See [`os_type`](CreateFileOptions::os_type).
272    pub os_creator: Option<[u8; 4]>,
273    /// Finder flags (fdFlags) for HFS/HFS+: bundle bit, custom icon, invisible.
274    /// `None` leaves them zero, as a freshly created file has them.
275    pub finder_flags: Option<u16>,
276    /// ProDOS auxiliary type (16-bit). Semantics depend on the file type:
277    /// $0801 for Applesoft BASIC load address, $2000 for typical BIN,
278    /// record length for random-access TXT, etc. Auto-detected from
279    /// extension if not set. Ignored on non-ProDOS filesystems.
280    pub aux_type: Option<u16>,
281    /// Optional resource fork data source (HFS/HFS+ only).
282    pub resource_fork: Option<ResourceForkSource>,
283    /// Extended attributes to stamp on the new file, on filesystems that store
284    /// them. Ignored elsewhere.
285    ///
286    /// Exists mainly so an **overwrite** can keep them. Replacing a file is
287    /// delete-then-create, and the create otherwise knows nothing about what it
288    /// displaced — so a binary carrying `security.capability` came back without
289    /// it and silently stopped working. Mode and ownership already survive a
290    /// replace via [`crate::fs::attrs::resolve_attrs`]; this is the same rule
291    /// for the attributes that don't live in the inode. See
292    /// [`crate::fs::attrs::inherited_xattrs`].
293    pub xattrs: Vec<super::xattr::Xattr>,
294    /// AmigaDOS protection bits (`access` word) for AFFS/FFS. The
295    /// on-disk default of 0 displays as `----rwed`. Ignored on
296    /// non-Amiga filesystems.
297    pub amiga_protection: Option<u32>,
298    /// AmigaDOS filenote (comment, up to 79 bytes). Ignored on
299    /// non-Amiga filesystems.
300    pub amiga_comment: Option<String>,
301    /// AmigaDOS raw datestamp triple `(days, minutes, ticks)` since
302    /// 1978-01-01. When set, written verbatim into the on-disk entry so
303    /// dates round-trip through clone/restore. Ignored on non-Amiga
304    /// filesystems. Default `None` leaves the date zero (which display
305    /// tools render as the epoch).
306    pub amiga_dates: Option<(i32, i32, i32)>,
307    /// When true, allocate clusters and create the directory entry but
308    /// skip writing file content.  The allocated clusters are left
309    /// uninitialized (zero on fresh images).  Used by the GHO
310    /// file-aware virtual-FAT builder to avoid writing 1 GB+ of zeros
311    /// that are immediately evicted.
312    pub skip_data_write: bool,
313    /// When true, skip the duplicate-name check and SFN collision scan
314    /// before creating the file.  The caller guarantees uniqueness
315    /// (e.g. entries come from a pre-deduplicated tree on a fresh FS).
316    /// Eliminates the O(n) parent-directory read per file.
317    pub skip_name_checks: bool,
318    /// When true, skip the per-operation FSInfo update (FAT32).
319    /// Caller must call `sync_metadata` when done to write a correct
320    /// FSInfo sector.  Eliminates two full FAT-table reads per file.
321    pub skip_fsinfo_update: bool,
322    /// Standard DOS attribute bits to stamp on the new file (read-only
323    /// `0x01`, hidden `0x02`, system `0x04`, archive `0x20`). Honored by
324    /// FAT and exFAT; ignored elsewhere. `None` lets the filesystem pick
325    /// its conventional default (FAT/exFAT use archive). See
326    /// [`crate::fs::entry::FileEntry::dos_attributes`].
327    pub dos_attributes: Option<u16>,
328    /// Preserve these Unix timestamps (seconds since 1970) on the new
329    /// inode instead of stamping the current time. Set by `dir_import` /
330    /// `tar_import` / Commander `stage_copy` so a copy carries the
331    /// source's mtime through end-to-end; left `None` for a genuinely new
332    /// file (rb-cli `put` from a `-` stdin stream, GUI "new blank file"),
333    /// where the driver stamps `now`. Ignored on filesystems that don't
334    /// carry per-file Unix times (FAT/exFAT/NTFS have their own scheme,
335    /// AmigaDOS uses `amiga_dates`, HFS uses `mac_dates`). See
336    /// [`crate::fs::times::UnixTimes`].
337    pub unix_times: Option<super::times::UnixTimes>,
338    /// QNX 2 permissions, owner and date copied exactly (from `FileEntry::qnx2_attrs`); overrides mode / uid / gid / unix_times.
339    pub qnx2_attrs: Option<super::entry::Qnx2Attrs>,
340}
341
342/// Options for creating a directory on an editable filesystem.
343#[derive(Debug, Clone, Default)]
344pub struct CreateDirectoryOptions {
345    /// Unix mode bits (default 0o40777). Ignored on FAT/exFAT.
346    pub mode: Option<u32>,
347    /// Unix user ID (default 0). Ignored on FAT/exFAT/NTFS.
348    pub uid: Option<u32>,
349    /// Unix group ID (default 0). Ignored on FAT/exFAT/NTFS.
350    pub gid: Option<u32>,
351    /// AmigaDOS protection bits. See `CreateFileOptions::amiga_protection`.
352    pub amiga_protection: Option<u32>,
353    /// AmigaDOS filenote (comment). See `CreateFileOptions::amiga_comment`.
354    pub amiga_comment: Option<String>,
355    /// AmigaDOS raw datestamp triple. See `CreateFileOptions::amiga_dates`.
356    pub amiga_dates: Option<(i32, i32, i32)>,
357    /// Preserve these Unix timestamps on the new directory inode. See
358    /// [`CreateFileOptions::unix_times`] for the full rule.
359    pub unix_times: Option<super::times::UnixTimes>,
360    /// QNX 2 permissions, owner and date copied exactly (from `FileEntry::qnx2_attrs`); overrides mode / uid / gid / unix_times.
361    pub qnx2_attrs: Option<super::entry::Qnx2Attrs>,
362}
363
364/// Source for resource fork data (HFS/HFS+ only).
365#[derive(Debug, Clone)]
366pub enum ResourceForkSource {
367    /// Read resource fork from a file on the host.
368    File(PathBuf),
369    /// Resource fork data provided directly.
370    Data(Vec<u8>),
371}
372
373/// Trait for filesystems that support write operations (add/delete files and folders).
374///
375/// Individual mutation methods (`create_file`, `delete_entry`, etc.) modify in-memory
376/// state only. The caller MUST call `sync_metadata()` after all mutations are complete
377/// to flush changes to disk. This enables batching multiple edits into a single atomic
378/// write.
379pub trait EditableFilesystem: Filesystem {
380    /// Enter "bulk" editing mode for a batch of operations whose atomicity the
381    /// *caller* guarantees as a whole (e.g. an importer that discards every
382    /// change if any single op fails). Implementations may use this to skip
383    /// per-operation rollback bookkeeping — for filesystems that snapshot the
384    /// whole metadata on each edit (classic HFS clones its multi-megabyte
385    /// catalog per `create_file`), that bookkeeping dominates a large import.
386    ///
387    /// Contract: while in bulk mode a *failed* edit may leave in-memory state
388    /// inconsistent, so the caller MUST abandon the volume (not sync/commit) on
389    /// any error. Always pair with [`end_bulk`](Self::end_bulk). The default is
390    /// a no-op, so filesystems whose edits are already cheap need do nothing.
391    fn begin_bulk(&mut self) {}
392
393    /// Leave bulk editing mode (see [`begin_bulk`](Self::begin_bulk)). Default
394    /// is a no-op.
395    fn end_bulk(&mut self) {}
396
397    /// Upcast to a read-only [`Filesystem`] trait object.
398    ///
399    /// A hand-rolled substitute for trait-upcasting coercion
400    /// (`&dyn EditableFilesystem` -> `&dyn Filesystem`), which is unstable
401    /// before Rust 1.86 — so the vintage macOS 10.7 build (Rust 1.73) can't rely
402    /// on the coercion and calls this instead. Every impl is a trivial `{ self }`
403    /// (the concrete type is `Sized` and already `Filesystem`, so the unsizing
404    /// coercion applies there). Modern builds could use the coercion directly,
405    /// but routing through this keeps a single code path.
406    fn as_filesystem(&self) -> &dyn Filesystem;
407
408    /// Mutable form of [`as_filesystem`](Self::as_filesystem).
409    fn as_filesystem_mut(&mut self) -> &mut dyn Filesystem;
410
411    /// Create a file in the given parent directory.
412    ///
413    /// `data` is a reader providing the file contents; `data_len` is the total size.
414    /// Returns the new file's entry.
415    fn create_file(
416        &mut self,
417        parent: &FileEntry,
418        name: &str,
419        data: &mut dyn std::io::Read,
420        data_len: u64,
421        options: &CreateFileOptions,
422    ) -> Result<FileEntry, FilesystemError>;
423
424    /// Create a subdirectory in the given parent directory.
425    fn create_directory(
426        &mut self,
427        parent: &FileEntry,
428        name: &str,
429        options: &CreateDirectoryOptions,
430    ) -> Result<FileEntry, FilesystemError>;
431
432    /// Delete a file, empty directory, or symlink.
433    ///
434    /// Returns an error if the entry is a non-empty directory.
435    fn delete_entry(
436        &mut self,
437        parent: &FileEntry,
438        entry: &FileEntry,
439    ) -> Result<(), FilesystemError>;
440
441    /// Recursively delete a directory and all its contents.
442    ///
443    /// Default implementation lists children, recurses, then calls `delete_entry`.
444    fn delete_recursive(
445        &mut self,
446        parent: &FileEntry,
447        entry: &FileEntry,
448    ) -> Result<(), FilesystemError> {
449        if entry.is_directory() {
450            let children = self.list_directory(entry)?;
451            for child in &children {
452                if child.is_directory() {
453                    self.delete_recursive(entry, child)?;
454                } else {
455                    self.delete_entry(entry, child)?;
456                }
457            }
458        }
459        self.delete_entry(parent, entry)
460    }
461
462    /// Rename an entry within its parent directory, in place: the entry keeps
463    /// its identity and contents (start cluster / CNID / inode and file data
464    /// are untouched) — only the name in the parent's directory listing
465    /// changes. `new_name` must validate via [`Filesystem::validate_name`] and
466    /// must not already name another entry in `parent`.
467    ///
468    /// Default returns `Unsupported` so the GUI can gray out the action on
469    /// filesystems that haven't implemented it yet.
470    fn rename(
471        &mut self,
472        _parent: &FileEntry,
473        _entry: &FileEntry,
474        _new_name: &str,
475    ) -> Result<(), FilesystemError> {
476        Err(FilesystemError::Unsupported(
477            "rename not supported for this filesystem".into(),
478        ))
479    }
480
481    /// Set Unix permission bits on an entry. `mode` carries permission bits
482    /// only — implementations keep the inode's file-type bits (see
483    /// [`crate::fs::unix_common::inode::with_permission_bits`]).
484    ///
485    /// Implemented by ext, EFS, UFS, Minix, JFS, XFS and SquashFS; anything
486    /// else returns `Unsupported` so callers don't silently lose mode
487    /// information.
488    fn set_permissions(&mut self, _entry: &FileEntry, _mode: u32) -> Result<(), FilesystemError> {
489        Err(FilesystemError::Unsupported(
490            "set_permissions not supported for this filesystem".into(),
491        ))
492    }
493
494    /// Set the owning uid and gid on an entry.
495    ///
496    /// Implemented by ext, EFS, UFS, Minix, JFS, XFS and SquashFS; others
497    /// return `Unsupported` so the UI can gate the control. Note the GUI
498    /// shows its Owner field whenever an entry carries a `mode`, which is a
499    /// read-side signal — that is how this went unimplemented on five Unix
500    /// filesystems whose editor fields rendered and then failed on apply.
501    ///
502    /// Filesystems with narrow id fields (EFS and Minix hold 16 bits, Minix
503    /// V1's gid is 8) reject an id that doesn't fit rather than truncating
504    /// it: a wrapped uid lands on 0, which is root. See
505    /// [`crate::fs::unix_common::inode::check_id_width`].
506    fn set_owner(
507        &mut self,
508        _entry: &FileEntry,
509        _uid: u32,
510        _gid: u32,
511    ) -> Result<(), FilesystemError> {
512        Err(FilesystemError::Unsupported(
513            "set_owner not supported for this filesystem".into(),
514        ))
515    }
516
517    /// Set (create or replace) an extended attribute on an entry. Override on
518    /// xattr-bearing filesystems (ext / XFS / SquashFS); others return
519    /// `Unsupported`. `name` must carry a valid namespace prefix
520    /// ([`crate::fs::xattr::has_valid_namespace`]).
521    fn set_xattr(
522        &mut self,
523        _entry: &FileEntry,
524        _name: &str,
525        _value: &[u8],
526    ) -> Result<(), FilesystemError> {
527        Err(FilesystemError::Unsupported(
528            "set_xattr not supported for this filesystem".into(),
529        ))
530    }
531
532    /// Remove an extended attribute from an entry. Override on xattr-bearing
533    /// filesystems; others return `Unsupported`. Removing an absent attribute is
534    /// not an error.
535    fn remove_xattr(&mut self, _entry: &FileEntry, _name: &str) -> Result<(), FilesystemError> {
536        Err(FilesystemError::Unsupported(
537            "remove_xattr not supported for this filesystem".into(),
538        ))
539    }
540
541    /// Set HFS/HFS+ type and creator codes. Override on HFS/HFS+; other
542    /// filesystems return `Unsupported` so callers can branch on capability
543    /// rather than relying on silent no-ops.
544    fn set_type_creator(
545        &mut self,
546        _entry: &FileEntry,
547        _type_code: &str,
548        _creator_code: &str,
549    ) -> Result<(), FilesystemError> {
550        Err(FilesystemError::Unsupported(
551            "set_type_creator not supported for this filesystem".into(),
552        ))
553    }
554
555    /// Set ProDOS file type byte and aux type on an existing file.
556    /// Default returns `Unsupported` — override on ProDOS.
557    fn set_prodos_type(
558        &mut self,
559        _entry: &FileEntry,
560        _type_byte: u8,
561        _aux_type: u16,
562    ) -> Result<(), FilesystemError> {
563        Err(FilesystemError::Unsupported(
564            "set_prodos_type not supported for this filesystem".into(),
565        ))
566    }
567
568    /// Set the ProDOS access byte on an existing file or directory.
569    ///
570    /// The access byte (offset 30 of a directory entry) is a bitmask:
571    /// `0x80` = enable destroy, `0x40` = enable rename, `0x20` = backup
572    /// required (set when the file changes; cleared by backup software),
573    /// `0x02` = enable write, `0x01` = enable read. Conventional values:
574    /// `0xC3` = unlocked (read/write/destroy/rename), `0x21` = locked
575    /// (read + backup-required).
576    ///
577    /// Default returns `Unsupported` — override on ProDOS.
578    fn set_prodos_access(
579        &mut self,
580        _entry: &FileEntry,
581        _access: u8,
582    ) -> Result<(), FilesystemError> {
583        Err(FilesystemError::Unsupported(
584            "set_prodos_access not supported for this filesystem".into(),
585        ))
586    }
587
588    /// Write resource fork data. Override on HFS/HFS+; other filesystems
589    /// return `Unsupported` rather than silently dropping the fork.
590    fn write_resource_fork(
591        &mut self,
592        _entry: &FileEntry,
593        _data: &mut dyn std::io::Read,
594        _len: u64,
595    ) -> Result<(), FilesystemError> {
596        Err(FilesystemError::Unsupported(
597            "write_resource_fork not supported for this filesystem".into(),
598        ))
599    }
600
601    /// Attempt to repair filesystem issues found by fsck.
602    /// Default returns an error indicating repair is not supported.
603    fn repair(&mut self) -> Result<super::fsck::RepairReport, FilesystemError> {
604        Err(FilesystemError::Unsupported(
605            "repair not supported for this filesystem".into(),
606        ))
607    }
608
609    /// Flush metadata (superblock, bitmaps, FAT tables, etc.) to disk.
610    fn sync_metadata(&mut self) -> Result<(), FilesystemError>;
611
612    /// Returns the number of free bytes available on the filesystem.
613    fn free_space(&mut self) -> Result<u64, FilesystemError>;
614
615    /// Mark a folder as the blessed (bootable) system folder. Override on
616    /// HFS/HFS+; other filesystems return `Unsupported` so the GUI can
617    /// gate the action and surface unexpected calls.
618    fn set_blessed_folder(&mut self, _entry: &FileEntry) -> Result<(), FilesystemError> {
619        Err(FilesystemError::Unsupported(
620            "set_blessed_folder not supported for this filesystem".into(),
621        ))
622    }
623
624    /// Stage the 1024-byte HFS boot-block region (sectors 0–1) for the
625    /// volume, written verbatim at `sync_metadata` time. Used to make a
626    /// classic-HFS volume bootable by copying a donor's boot blocks. Override
627    /// on HFS/HFS+ (the only filesystems with a Mac boot region); others
628    /// return `Unsupported` so the GUI can gate the action.
629    fn write_boot_blocks(&mut self, _blocks: &[u8; 1024]) -> Result<(), FilesystemError> {
630        Err(FilesystemError::Unsupported(
631            "write_boot_blocks not supported for this filesystem".into(),
632        ))
633    }
634
635    /// Rename the volume. Override on filesystems where the volume name is
636    /// stored in metadata that the implementation can rewrite in place
637    /// (HFS today; others return `Unsupported`).
638    fn set_volume_name(&mut self, _new_name: &str) -> Result<(), FilesystemError> {
639        Err(FilesystemError::Unsupported(
640            "set_volume_name not supported for this filesystem".into(),
641        ))
642    }
643
644    /// Overwrite the full 16-byte FInfo and 16-byte FXInfo Finder metadata
645    /// on a file's catalog record. HFS today; others `Unsupported`. Used
646    /// by the MacBinary put path to apply Finder flags / location / folder
647    /// in one shot.
648    fn set_finder_info(
649        &mut self,
650        _entry: &FileEntry,
651        _finfo: [u8; 16],
652        _fxinfo: [u8; 16],
653    ) -> Result<(), FilesystemError> {
654        Err(FilesystemError::Unsupported(
655            "set_finder_info not supported for this filesystem".into(),
656        ))
657    }
658
659    /// Overwrite the create/modify/backup dates on a file's catalog
660    /// record. Dates are Mac epoch (seconds since 1904-01-01 00:00 UTC).
661    /// HFS today; others `Unsupported`.
662    /// Set the DOS attribute bits on an entry: read-only `0x01`, hidden `0x02`,
663    /// system `0x04`, archive `0x20`.
664    ///
665    /// Takes the parent because that is where the bits live — a FAT directory
666    /// entry is the record, and there is nothing else to address it by.
667    /// Structural bits (directory `0x10`, volume-id `0x08`) are the driver's to
668    /// manage and are masked off rather than trusted from a caller.
669    ///
670    /// `Unsupported` on filesystems with no such concept, which is most of them.
671    /// Set the AmigaDOS protection bits on an entry (the `access` longword:
672    /// HSPA RWED, held at offset 0x140 of the file header block).
673    ///
674    /// `Unsupported` off the Amiga filesystems, which is everywhere else.
675    fn set_amiga_protection(
676        &mut self,
677        _entry: &FileEntry,
678        _protection: u32,
679    ) -> Result<(), FilesystemError> {
680        Err(FilesystemError::Unsupported(
681            "AmigaDOS protection bits are not supported by this filesystem".into(),
682        ))
683    }
684
685    fn set_dos_attributes(
686        &mut self,
687        _parent: &FileEntry,
688        _entry: &FileEntry,
689        _attrs: u16,
690    ) -> Result<(), FilesystemError> {
691        Err(FilesystemError::Unsupported(
692            "DOS attribute bits are not supported by this filesystem".into(),
693        ))
694    }
695
696    fn set_dates(
697        &mut self,
698        _entry: &FileEntry,
699        _create: u32,
700        _modify: u32,
701        _backup: u32,
702    ) -> Result<(), FilesystemError> {
703        Err(FilesystemError::Unsupported(
704            "set_dates not supported for this filesystem".into(),
705        ))
706    }
707
708    /// Whether this filesystem can store symbolic links (i.e.
709    /// [`Self::create_symlink`] is actually implemented). Default `false`;
710    /// override to `true` alongside `create_symlink`. Lets callers (e.g. the
711    /// tar-import preflight) warn *before* a lossy import that symlinks will
712    /// be dropped, rather than discovering it entry-by-entry.
713    fn supports_symlinks(&self) -> bool {
714        false
715    }
716
717    /// Whether names are stored as raw bytes via [`crate::fs::raw_name`] placeholders.
718    /// Only NeXT UFS volumes say yes; everywhere else a placeholder would land as junk.
719    fn stores_raw_names(&self) -> bool {
720        false
721    }
722
723    /// Create a symbolic link in `parent` pointing at `target` (a path
724    /// string interpreted by the filesystem's own resolver semantics).
725    /// Returns the new symlink's entry. Default returns `Unsupported` —
726    /// override on filesystems that support symlinks (PFS3, AFFS,
727    /// ext, etc).
728    fn create_symlink(
729        &mut self,
730        _parent: &FileEntry,
731        _name: &str,
732        _target: &str,
733        _options: &CreateFileOptions,
734    ) -> Result<FileEntry, FilesystemError> {
735        Err(FilesystemError::Unsupported(
736            "create_symlink not supported for this filesystem".into(),
737        ))
738    }
739
740    /// Create a hard link in `parent` pointing at an existing entry
741    /// already on this filesystem. `target` must be an entry returned
742    /// by a previous `create_file` / `create_directory` / `list_directory`
743    /// call on this same volume — its `location` identifies the inode /
744    /// anode / CNID that the new link references. Returns the new
745    /// hardlink's entry. Default returns `Unsupported` — override on
746    /// filesystems that support hardlinks (PFS3, HFS+, ext, etc).
747    fn create_hardlink(
748        &mut self,
749        _parent: &FileEntry,
750        _name: &str,
751        _target: &FileEntry,
752        _options: &CreateFileOptions,
753    ) -> Result<FileEntry, FilesystemError> {
754        Err(FilesystemError::Unsupported(
755            "create_hardlink not supported for this filesystem".into(),
756        ))
757    }
758}
759
760/// The child of a listing that `name` addresses: an exact match first, then,
761/// when the filesystem folds case, the way its native OS would match (R-062).
762pub fn find_child<'a>(
763    fold_case: bool,
764    children: &'a [FileEntry],
765    name: &str,
766) -> Option<&'a FileEntry> {
767    children.iter().find(|c| c.name == name).or_else(|| {
768        fold_case
769            .then(|| children.iter().find(|c| c.name.eq_ignore_ascii_case(name)))
770            .flatten()
771    })
772}
773
774/// Walk `components` from the root, rejoining neighbours with `/` when a name
775/// holds a literal slash (legal on HFS); see docs/commander_mode.md "Slashes in names".
776pub fn resolve_components_joined(
777    fs: &mut dyn Filesystem,
778    components: &[&str],
779    want_dir: bool,
780) -> Result<Option<FileEntry>, FilesystemError> {
781    let root = fs.root()?;
782    if components.is_empty() {
783        return Ok(Some(root));
784    }
785    let fold = fs.case_insensitive_lookup();
786    walk_joined(fs, &root, components, want_dir, fold)
787}
788
789fn walk_joined(
790    fs: &mut dyn Filesystem,
791    dir: &FileEntry,
792    rest: &[&str],
793    want_dir: bool,
794    fold: bool,
795) -> Result<Option<FileEntry>, FilesystemError> {
796    let children = fs.list_directory(dir)?;
797    // Fewest components first, so a path that resolves strictly never takes a joined branch.
798    for take in 1..=rest.len() {
799        let name = rest[..take].join("/");
800        let Some(child) = find_child(fold, &children, &name) else {
801            continue;
802        };
803        let tail = &rest[take..];
804        if tail.is_empty() {
805            if !want_dir || child.is_directory() {
806                return Ok(Some(child.clone()));
807            }
808        } else if child.is_directory() {
809            let child = child.clone();
810            if let Some(found) = walk_joined(fs, &child, tail, want_dir, fold)? {
811                return Ok(Some(found));
812            }
813        }
814    }
815    Ok(None)
816}
817
818/// The filesystems whose drivers refuse `readme.txt` beside `README.TXT`, so a
819/// lookup that only matched exactly would see no conflict where they see one.
820pub fn folds_case(fs_type: &str) -> bool {
821    let t = fs_type;
822    t.starts_with("FAT")
823        || t == "exFAT"
824        || t.starts_with("NTFS")
825        || (t.starts_with("HFS") && t != "HFSX")
826        || t == "MFS"
827        || t.starts_with("HPFS")
828        || t == "ProDOS"
829        || t.starts_with("Human68k")
830        || t.starts_with("OFS")
831        || t.starts_with("FFS")
832        || t.starts_with("PFS")
833        || t == "AFS"
834        || t == "muPFS"
835        || t == "SFS"
836}
837
838/// Result of filesystem compaction.
839///
840/// See `src/fs/README.md` ("Compact reader sizing model") for the full
841/// description of how `original_size`, `compacted_size`, and `data_size`
842/// relate for packed vs. layout-preserving readers.
843pub struct CompactResult {
844    pub original_size: u64,
845    /// Actual bytes that the compact reader will emit (= `original_size` for
846    /// layout-preserving readers; < `original_size` for packed readers).
847    pub compacted_size: u64,
848    /// Logical data bytes: allocated clusters × block_size (+ pre-alloc for HFS).
849    /// For packed readers this equals `compacted_size`.
850    /// For layout-preserving readers this is less than `compacted_size` because
851    /// free clusters are zero-filled in-memory rather than read from disk.
852    pub data_size: u64,
853    pub clusters_used: u32,
854}
855
856#[cfg(test)]
857mod find_child_tests {
858    use super::*;
859
860    #[test]
861    fn find_child_prefers_exact_and_folds_only_when_asked() {
862        let kids = vec![
863            FileEntry::new_file("README.TXT".into(), "/README.TXT".into(), 1, 1),
864            FileEntry::new_file("readme.txt".into(), "/readme.txt".into(), 2, 2),
865            FileEntry::new_file("EXTRA.BIN".into(), "/EXTRA.BIN".into(), 3, 3),
866        ];
867        assert_eq!(
868            find_child(true, &kids, "readme.txt").map(|e| e.location),
869            Some(2)
870        );
871        assert_eq!(
872            find_child(true, &kids, "extra.bin").map(|e| e.location),
873            Some(3)
874        );
875        assert!(find_child(false, &kids, "extra.bin").is_none());
876        assert!(find_child(true, &kids, "missing").is_none());
877    }
878}