Skip to main content

disk_core/
entry.rs

1/// A file or directory entry within a partition's filesystem.
2#[derive(Debug, Clone)]
3pub struct FileEntry {
4    pub name: String,
5    pub path: String,
6    pub entry_type: EntryType,
7    pub size: u64,
8    /// Starting cluster (FAT) or inode/extent for other filesystems.
9    pub location: u64,
10    /// Human-readable modification date string.
11    pub modified: Option<String>,
12    /// Modification time in seconds since UNIX epoch 1970-01-01. The numeric
13    /// twin of `modified` — the display string is for the browser/`ls`, this
14    /// is what `tar_export` writes into its headers and what a `stage_copy`
15    /// passes into `CreateFileOptions.unix_times` so a cross-image copy
16    /// preserves the source's date instead of stamping the current time.
17    /// Populated by every filesystem driver whose on-disk format carries a
18    /// per-file mtime (all Unix / Amiga / classic-Mac / DOS families);
19    /// `None` on formats that don't (Apple DOS 3.3, TRS-80, etc.).
20    pub modified_unix: Option<u64>,
21    /// HFS/HFS+/MFS file type as the raw 4-byte Mac `OSType`, exactly as
22    /// stored on disk (e.g. `*b"APPL"`, `*b"PICT"`). Kept as bytes — not text —
23    /// because an `OSType` may hold non-ASCII bytes (e.g. Prince of Persia's
24    /// `PoƒP` creator has `0xC4`), and round-trip writers (`get-binhex`, `cp`,
25    /// `tar`, archives) must preserve them verbatim. For display, render via
26    /// [`FileEntry::type_code_display`] (lossy) — never rebuild bytes from it.
27    /// `None` on non-Mac filesystems (ProDOS uses [`prodos_file_type`]).
28    pub type_code: Option<[u8; 4]>,
29    /// HFS/HFS+/MFS creator as the raw 4-byte Mac `OSType`. See
30    /// [`type_code`](FileEntry::type_code); display via
31    /// [`creator_code_display`](FileEntry::creator_code_display).
32    pub creator_code: Option<[u8; 4]>,
33    /// HFS/HFS+/MFS Finder flags (`FInfo.fdFlags`): `hasBundle` (0x2000),
34    /// `hasCustomIcon` (0x0400), `isInvisible` (0x4000), `nameLocked`,
35    /// `isStationery`, the `kIsAlias` bit (0x8000), etc. The 2-byte field at
36    /// offset 8 of the 16-byte Finder info. `None` on non-Mac filesystems.
37    /// Carried so copy / `get-binhex` round-trips preserve a file's real icon
38    /// (a dropped `hasBundle` makes an app and its documents show generic
39    /// icons). See `docs/bug_binhex_finder_flags.md`.
40    pub finder_flags: Option<u16>,
41    /// Symlink target path (only set for `EntryType::Symlink`).
42    pub symlink_target: Option<String>,
43    /// Special file type description (e.g. "block device", "char device", "socket", "fifo").
44    /// Only set for `EntryType::Special`.
45    pub special_type: Option<String>,
46    /// Raw Unix mode bits (e.g. 0o100755). Present on ext/btrfs/xfs/UFS filesystems.
47    pub mode: Option<u32>,
48    /// Unix user ID. Present on ext/btrfs/xfs/UFS filesystems.
49    pub uid: Option<u32>,
50    /// Unix group ID. Present on ext/btrfs/xfs/UFS filesystems.
51    pub gid: Option<u32>,
52    /// Resource fork size (HFS/HFS+ only). None or Some(0) means no resource fork.
53    pub resource_fork_size: Option<u64>,
54    /// ProDOS auxiliary type (16-bit). Semantics depend on the file type
55    /// (e.g. `$0801` = Applesoft BASIC load address, load addr for BIN,
56    /// record length for random-access TXT). Only set for ProDOS entries.
57    pub aux_type: Option<u16>,
58    /// ProDOS file type — the raw 1-byte type value (e.g. `$04` = TXT,
59    /// `$06` = BIN, `$FF` = SYS). ProDOS has no Mac `OSType`, so it uses this
60    /// instead of [`type_code`](FileEntry::type_code). Rendered for display by
61    /// [`type_code_display`](FileEntry::type_code_display) via
62    /// `prodos_types::format_type_code`. Only set for ProDOS entries.
63    pub prodos_file_type: Option<u8>,
64    /// HFS+ hardlink target inode CNID. Set on entries whose catalog row
65    /// is a hardlink stub (`fdType='hlnk' fdCreator='hfs+'`); the link's
66    /// data and resource forks live on the inode at this CNID under
67    /// `HFS+ Private Data`. `read_file` / `write_file_to` follow the
68    /// indirection automatically. `None` for ordinary files.
69    pub link_target_cnid: Option<u64>,
70    /// AmigaDOS protection word (`access` field of an AFFS file header).
71    /// `0` displays as `----rwed`. Only set for AmigaDOS / Fast File
72    /// System entries.
73    pub amiga_protection: Option<u32>,
74    /// AmigaDOS filenote (comment), up to 79 bytes. Only set for
75    /// AmigaDOS / Fast File System entries; `None` outside that family.
76    pub amiga_comment: Option<String>,
77    /// AmigaDOS raw datestamp triple `(days, minutes, ticks)` from the
78    /// on-disk entry. Days since 1978-01-01, minutes within the day,
79    /// and ticks (1/50s) within the minute. Preserved verbatim for
80    /// byte-exact round-trips through clone/restore. AFFS stores these
81    /// as i32 on disk; PFS3 direntries narrow them to u16 — we widen
82    /// to i32 so both round-trip losslessly. The display string lives
83    /// in `modified`. Only set for AmigaDOS / PFS3 / SFS entries;
84    /// `None` outside that family.
85    pub amiga_date: Option<(i32, i32, i32)>,
86    /// Standard DOS attribute bits (read-only `0x01`, hidden `0x02`,
87    /// system `0x04`, archive `0x20`; the volume-label `0x08` and
88    /// directory `0x10` bits are represented by `entry_type` and not
89    /// carried here). Populated for FAT (the on-disk `u8` widened) and
90    /// exFAT (the directory entry's `u16` FileAttributes). `None` for
91    /// filesystems that have no DOS attribute concept. The copy engine
92    /// carries this across FAT/exFAT destinations.
93    pub dos_attributes: Option<u16>,
94    /// HFS/HFS+/MFS raw catalog dates `(create, modify, backup)` in Mac-epoch
95    /// seconds (since 1904-01-01 UTC). Kept raw so the detail pane can both
96    /// display them (via `hfs_common::format_mac_date`) and stage an in-place
97    /// edit (`StagedEdit::SetDates`). The `modify` value is also surfaced as a
98    /// formatted string in `modified`. `None` outside the HFS family.
99    pub mac_dates: Option<(u32, u32, u32)>,
100    /// Owner in the filesystem's own notation (QNX 2 `group.member`); shown instead of uid:gid.
101    pub owner_label: Option<String>,
102    /// Native attribute/permission letters for formats whose bits do not map onto a Unix mode.
103    pub native_attrs: Option<String>,
104    /// QNX 2 permissions, owner and raw date words, so QNX 2 to QNX 2 copies keep them exactly.
105    pub qnx2_attrs: Option<Qnx2Attrs>,
106}
107
108/// Raw QNX 2 attributes; the same fields as the `qnx2fs` crate's `Attributes`, which converts to and from it.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct Qnx2Attrs {
111    /// Owner attributes (`rwaem` for files, `rcbm` for directories).
112    pub attr: u8,
113    pub group_perms: u8,
114    pub other_perms: u8,
115    pub member: u8,
116    pub group: u8,
117    /// The raw QNX 2 date word pair.
118    pub date: [u16; 2],
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
122pub enum EntryType {
123    File,
124    Directory,
125    /// Symbolic link. The target path is stored in `FileEntry::symlink_target`.
126    Symlink,
127    /// Special file: block device, char device, socket, or FIFO.
128    /// The kind is stored in `FileEntry::special_type`.
129    Special,
130}
131
132impl FileEntry {
133    pub fn root() -> Self {
134        Self {
135            name: "/".into(),
136            path: "/".into(),
137            entry_type: EntryType::Directory,
138            size: 0,
139            location: 0,
140            modified: None,
141            modified_unix: None,
142            type_code: None,
143            creator_code: None,
144            finder_flags: None,
145            prodos_file_type: None,
146            symlink_target: None,
147            special_type: None,
148            mode: None,
149            uid: None,
150            gid: None,
151            resource_fork_size: None,
152            aux_type: None,
153            link_target_cnid: None,
154            amiga_protection: None,
155            amiga_comment: None,
156            amiga_date: None,
157            dos_attributes: None,
158            mac_dates: None,
159            owner_label: None,
160            native_attrs: None,
161            qnx2_attrs: None,
162        }
163    }
164
165    pub fn new_directory(name: String, path: String, location: u64) -> Self {
166        Self {
167            name,
168            path,
169            entry_type: EntryType::Directory,
170            size: 0,
171            location,
172            modified: None,
173            modified_unix: None,
174            type_code: None,
175            creator_code: None,
176            finder_flags: None,
177            prodos_file_type: None,
178            symlink_target: None,
179            special_type: None,
180            mode: None,
181            uid: None,
182            gid: None,
183            resource_fork_size: None,
184            aux_type: None,
185            link_target_cnid: None,
186            amiga_protection: None,
187            amiga_comment: None,
188            amiga_date: None,
189            dos_attributes: None,
190            mac_dates: None,
191            owner_label: None,
192            native_attrs: None,
193            qnx2_attrs: None,
194        }
195    }
196
197    pub fn new_file(name: String, path: String, size: u64, location: u64) -> Self {
198        Self {
199            name,
200            path,
201            entry_type: EntryType::File,
202            size,
203            location,
204            modified: None,
205            modified_unix: None,
206            type_code: None,
207            creator_code: None,
208            finder_flags: None,
209            prodos_file_type: None,
210            symlink_target: None,
211            special_type: None,
212            mode: None,
213            uid: None,
214            gid: None,
215            resource_fork_size: None,
216            aux_type: None,
217            link_target_cnid: None,
218            amiga_protection: None,
219            amiga_comment: None,
220            amiga_date: None,
221            dos_attributes: None,
222            mac_dates: None,
223            owner_label: None,
224            native_attrs: None,
225            qnx2_attrs: None,
226        }
227    }
228
229    pub fn new_symlink(
230        name: String,
231        path: String,
232        size: u64,
233        location: u64,
234        target: String,
235    ) -> Self {
236        Self {
237            name,
238            path,
239            entry_type: EntryType::Symlink,
240            size,
241            location,
242            modified: None,
243            modified_unix: None,
244            type_code: None,
245            creator_code: None,
246            finder_flags: None,
247            prodos_file_type: None,
248            symlink_target: Some(target),
249            special_type: None,
250            mode: None,
251            uid: None,
252            gid: None,
253            resource_fork_size: None,
254            aux_type: None,
255            link_target_cnid: None,
256            amiga_protection: None,
257            amiga_comment: None,
258            amiga_date: None,
259            dos_attributes: None,
260            mac_dates: None,
261            owner_label: None,
262            native_attrs: None,
263            qnx2_attrs: None,
264        }
265    }
266
267    pub fn new_special(
268        name: String,
269        path: String,
270        location: u64,
271        special_type_str: String,
272    ) -> Self {
273        Self {
274            name,
275            path,
276            entry_type: EntryType::Special,
277            size: 0,
278            location,
279            modified: None,
280            modified_unix: None,
281            type_code: None,
282            creator_code: None,
283            finder_flags: None,
284            prodos_file_type: None,
285            symlink_target: None,
286            special_type: Some(special_type_str),
287            mode: None,
288            uid: None,
289            gid: None,
290            resource_fork_size: None,
291            aux_type: None,
292            link_target_cnid: None,
293            amiga_protection: None,
294            amiga_comment: None,
295            amiga_date: None,
296            dos_attributes: None,
297            mac_dates: None,
298            owner_label: None,
299            native_attrs: None,
300            qnx2_attrs: None,
301        }
302    }
303
304    /// The name this entry should carry inside an archive or an extraction
305    /// folder — or `None` when it is a volume root, which contributes its
306    /// *children* rather than a member of its own.
307    ///
308    /// Every driver names the volume root `"/"` (the optical drivers map their
309    /// empty root name onto it too), and that is not a usable member name:
310    /// tar and zip both reject absolute paths, and `Path::join("/")` silently
311    /// throws away the destination directory and hands back the **host** root.
312    /// So an exporter handed a whole volume must ask here rather than reach for
313    /// `name` — the answer is "this has no name, recurse into it at the same
314    /// level", which is also what `tar -C / .` does.
315    pub fn archive_name(&self) -> Option<&str> {
316        let trimmed = self.name.trim_matches('/');
317        // ".." would escape the destination on extraction, "." names the
318        // directory an archive is already rooted at; neither is a member.
319        if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
320            None
321        } else {
322            Some(trimmed)
323        }
324    }
325
326    pub fn is_directory(&self) -> bool {
327        self.entry_type == EntryType::Directory
328    }
329
330    pub fn is_file(&self) -> bool {
331        self.entry_type == EntryType::File
332    }
333
334    pub fn is_symlink(&self) -> bool {
335        self.entry_type == EntryType::Symlink
336    }
337
338    pub fn is_special(&self) -> bool {
339        self.entry_type == EntryType::Special
340    }
341
342    /// Returns a human-readable Unix permission string (e.g. "drwxr-xr-x"),
343    /// or `None` if mode bits are not available (non-Unix filesystems).
344    pub fn mode_string(&self) -> Option<String> {
345        self.mode.map(crate::display::unix_mode_string)
346    }
347
348    pub fn size_string(&self) -> String {
349        if self.is_directory() || self.is_special() {
350            return String::new();
351        }
352        crate::display::format_size(self.size)
353    }
354
355    /// Human-readable creator string for display. Mac `OSType` only (ProDOS
356    /// has no creator), rendered lossily by [`crate::display::decode_ostype`].
357    pub fn creator_code_display(&self) -> Option<String> {
358        self.creator_code
359            .as_ref()
360            .map(crate::display::decode_ostype)
361    }
362}
363
364/// Display order for a directory listing: directories first, then everything
365/// else, each group by name case-insensitively.
366///
367/// Drivers return entries in on-disk order (insertion order on FAT, B-tree
368/// order on HFS, and so on), which is why the browse tree looked unsorted.
369/// This is display-only — `ls` and the archive writers keep the driver's order,
370/// which is what the regression cases assert against.
371pub fn sort_for_display(entries: &mut [FileEntry]) {
372    entries.sort_by_cached_key(|e| (!e.is_directory(), e.name.to_lowercase()));
373}
374
375#[cfg(test)]
376mod sort_tests {
377    use super::*;
378
379    fn e(name: &str, dir: bool) -> FileEntry {
380        let mut x = FileEntry::root();
381        x.name = name.to_string();
382        x.path = format!("/{name}");
383        x.entry_type = if dir {
384            EntryType::Directory
385        } else {
386            EntryType::File
387        };
388        x
389    }
390
391    #[test]
392    fn directories_come_first_then_case_insensitive_name() {
393        let mut v = vec![
394            e("zebra.txt", false),
395            e("Apple", true),
396            e("banana.TXT", false),
397            e("apple.txt", false),
398            e("Zoo", true),
399        ];
400        sort_for_display(&mut v);
401        let got: Vec<&str> = v.iter().map(|x| x.name.as_str()).collect();
402        assert_eq!(
403            got,
404            ["Apple", "Zoo", "apple.txt", "banana.TXT", "zebra.txt"],
405            "folders first, then files, each case-insensitively by name"
406        );
407    }
408
409    /// The bug this fixes: an uppercase name must not sort before every
410    /// lowercase one, which is what a plain byte-order sort does.
411    #[test]
412    fn uppercase_does_not_sort_ahead_of_lowercase() {
413        let mut v = vec![e("beta", false), e("Alpha", false), e("gamma", false)];
414        sort_for_display(&mut v);
415        let got: Vec<&str> = v.iter().map(|x| x.name.as_str()).collect();
416        assert_eq!(got, ["Alpha", "beta", "gamma"]);
417    }
418}