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}