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}