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