disk_core/attrs.rs
1//! Where a new file's POSIX mode / uid / gid actually come from.
2//!
3//! Every Unix filesystem we can edit — ext, UFS, XFS, EFS, Minix, and SquashFS
4//! once its edit path lands — already honours `CreateFileOptions`'s `mode`,
5//! `uid` and `gid`. Until now *nothing set them*: `rb-cli put` left all three at
6//! `None`, so each driver fell back to its own `unwrap_or` and every added file
7//! silently became **root:root 0644**.
8//!
9//! That is defensible as a last resort and wrong as a default. Replacing
10//! `/etc/shadow` should not turn it world-readable; dropping a script into
11//! `/usr/local/bin` should not make it non-executable; and replacing a
12//! capability-bearing binary should not quietly strip its ownership.
13//!
14//! This module is the one place that decides, so the CLI, GUI and TUI cannot
15//! drift. The rule, highest priority first:
16//!
17//! | Attribute | Precedence |
18//! |---|---|
19//! | `mode` | explicit flag -> file being replaced -> host file's own bits -> fallback |
20//! | `uid` | explicit flag -> file being replaced -> parent directory -> 0 |
21//! | `gid` | explicit flag -> file being replaced -> parent directory -> 0 |
22//!
23//! **Replacing an existing file inherits from it**, because that is the common
24//! edit — swap a config, replace a binary — and the file already carries the
25//! answer. **A genuinely new file takes ownership from its parent directory**,
26//! which is how the surrounding tree is almost always laid out. The host file's
27//! permission bits are consulted for `mode` only: macOS carries those faithfully,
28//! but its uid/gid (typically 501:20) are meaningless inside a Linux image.
29//!
30//! Each resolved value records *where it came from* so the operation can say so
31//! rather than guessing silently — see [`ResolvedAttrs::describe`].
32
33use crate::entry::FileEntry;
34
35/// Which input supplied a resolved attribute.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AttrSource {
38 /// An explicit `--mode` / `--uid` / `--gid`, or the GUI's equivalent field.
39 Explicit,
40 /// Inherited from the file being overwritten.
41 Replaced,
42 /// Inherited from the containing directory.
43 Parent,
44 /// Taken from the host file's own permission bits (`mode` only).
45 HostFile,
46 /// Nothing else applied.
47 Fallback,
48}
49
50impl AttrSource {
51 /// ASCII-only label for log lines (no Unicode glyphs — see CONTRIBUTING).
52 pub fn label(self) -> &'static str {
53 match self {
54 Self::Explicit => "explicit",
55 Self::Replaced => "from replaced file",
56 Self::Parent => "from parent dir",
57 Self::HostFile => "from host file",
58 Self::Fallback => "default",
59 }
60 }
61}
62
63/// Caller-supplied overrides. `None` means "decide for me".
64#[derive(Debug, Default, Clone, Copy)]
65pub struct AttrOverrides {
66 /// Permission bits; the file-type bits are the driver's business.
67 pub mode: Option<u32>,
68 pub uid: Option<u32>,
69 pub gid: Option<u32>,
70 /// Preserved Unix mtime/atime/ctime from the source (host file,
71 /// tar Header, or source-image inode). Threaded through the shared
72 /// importer so `create_file` records the source's dates instead of
73 /// stamping the current time. See [`crate::times::UnixTimes`].
74 pub unix_times: Option<crate::times::UnixTimes>,
75}
76
77impl AttrOverrides {
78 /// True when the caller specified nothing at all.
79 pub fn is_empty(&self) -> bool {
80 self.mode.is_none() && self.uid.is_none() && self.gid.is_none() && self.unix_times.is_none()
81 }
82
83 /// True when a mode or owner was specified; a date alone is not a permission.
84 pub fn has_permissions(&self) -> bool {
85 self.mode.is_some() || self.uid.is_some() || self.gid.is_some()
86 }
87}
88
89/// The decision, with provenance per field.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct ResolvedAttrs {
92 /// Permission bits only (`0o7777`).
93 pub mode: u32,
94 pub uid: u32,
95 pub gid: u32,
96 pub mode_from: AttrSource,
97 pub uid_from: AttrSource,
98 pub gid_from: AttrSource,
99}
100
101impl ResolvedAttrs {
102 /// One-line ASCII summary naming where each value came from, so a `put`
103 /// that inherits `0600 root:root` from the file it replaced says so instead
104 /// of leaving the user to discover it later.
105 pub fn describe(&self) -> String {
106 format!(
107 "mode {:04o} ({}), uid {} ({}), gid {} ({})",
108 self.mode,
109 self.mode_from.label(),
110 self.uid,
111 self.uid_from.label(),
112 self.gid,
113 self.gid_from.label(),
114 )
115 }
116
117 /// The mode as `CreateFileOptions::mode` wants it for a **regular file** —
118 /// permission bits OR'd with `S_IFREG`.
119 ///
120 /// Every driver takes that field as the *complete* mode (ext, UFS and EFS
121 /// default it to `0o100644` and write it straight into the inode), so
122 /// handing them bare permission bits would produce an inode with no type.
123 /// Going through these two helpers keeps that from being rediscovered.
124 pub fn file_mode(&self) -> u32 {
125 0o100_000 | (self.mode & 0o7777)
126 }
127
128 /// The mode for a **directory** — permission bits OR'd with `S_IFDIR`.
129 pub fn dir_mode(&self) -> u32 {
130 0o040_000 | (self.mode & 0o7777)
131 }
132
133 /// True when every field fell through to its last-resort value — the case
134 /// worth warning about, because it is the old silent `root:root 0644`.
135 pub fn is_all_fallback(&self) -> bool {
136 self.mode_from == AttrSource::Fallback
137 && self.uid_from == AttrSource::Fallback
138 && self.gid_from == AttrSource::Fallback
139 }
140}
141
142/// Permission bits carried by a browse entry, if it has any.
143fn perms_of(entry: &FileEntry) -> Option<u32> {
144 entry.mode.map(|m| m & 0o7777)
145}
146
147/// Resolve the POSIX attributes for a file about to be created.
148///
149/// * `replacing` — the entry being overwritten, when this is a replace.
150/// * `parent` — the directory it is being created in.
151/// * `host_mode` — the source host file's mode, when the content came from one.
152/// * `fallback_mode` — last resort (`0o644` for files, `0o755` for directories).
153///
154/// See the module docs for the precedence table.
155pub fn resolve_attrs(
156 overrides: &AttrOverrides,
157 replacing: Option<&FileEntry>,
158 parent: Option<&FileEntry>,
159 host_mode: Option<u32>,
160 fallback_mode: u32,
161) -> ResolvedAttrs {
162 let (mode, mode_from) = if let Some(m) = overrides.mode {
163 (m & 0o7777, AttrSource::Explicit)
164 } else if let Some(m) = replacing.and_then(perms_of) {
165 (m, AttrSource::Replaced)
166 } else if let Some(m) = host_mode {
167 (m & 0o7777, AttrSource::HostFile)
168 } else {
169 (fallback_mode & 0o7777, AttrSource::Fallback)
170 };
171
172 let (uid, uid_from) = resolve_id(
173 overrides.uid,
174 replacing.and_then(|e| e.uid),
175 parent.and_then(|e| e.uid),
176 );
177 let (gid, gid_from) = resolve_id(
178 overrides.gid,
179 replacing.and_then(|e| e.gid),
180 parent.and_then(|e| e.gid),
181 );
182
183 ResolvedAttrs {
184 mode,
185 uid,
186 gid,
187 mode_from,
188 uid_from,
189 gid_from,
190 }
191}
192
193/// Add execute wherever read is already granted — exactly `chmod a+X`.
194///
195/// A directory's execute bit means "may be traversed", so a directory with read
196/// but no execute can be listed and not entered, which is almost never what
197/// anyone wants. Note this deliberately does **not** grant execute to classes
198/// that have no read: forcing `o+x` on everything would widen `/root` (0700),
199/// `~/.ssh` (0700) and `/etc/ssl/private` — a real security regression, not a
200/// convenience.
201pub fn add_execute_where_read(mode: u32) -> u32 {
202 let mut out = mode;
203 for shift in [6, 3, 0] {
204 if mode >> shift & 0o4 != 0 {
205 out |= 0o1 << shift;
206 }
207 }
208 out
209}
210
211/// The extended attributes a replacement file should inherit from the file it
212/// displaces.
213///
214/// The counterpart of [`resolve_attrs`] for the attributes that don't live in
215/// the inode. Overwriting is delete-then-create, and the create knows nothing
216/// about what was there — so on a filesystem that stores xattrs, replacing a
217/// binary that carried `security.capability` produced one without it: a file
218/// that still runs, still looks right, and no longer has the privilege it needs.
219/// The mode and ownership rules already say a replacement inherits from what it
220/// replaces; this applies the same rule to xattrs.
221///
222/// Pass the result to [`CreateFileOptions::xattrs`](
223/// crate::filesystem::CreateFileOptions::xattrs). Returns empty for a new
224/// file, or on a filesystem that stores no xattrs — both correctly meaning
225/// "nothing to carry over".
226///
227/// Note the caller must capture this **before** deleting the old entry.
228pub fn inherited_xattrs(
229 fs: &mut dyn crate::filesystem::Filesystem,
230 replacing: Option<&FileEntry>,
231) -> Vec<crate::xattr::Xattr> {
232 let Some(entry) = replacing else {
233 return Vec::new();
234 };
235 if !fs.supports_xattrs() {
236 return Vec::new();
237 }
238 // A file we cannot read attributes off is not a reason to abort the write;
239 // the worst case is the status quo, which is that they are dropped.
240 fs.list_xattrs(entry).unwrap_or_default()
241}
242
243/// Resolve the POSIX attributes for a **directory** about to be created.
244///
245/// Differs from [`resolve_attrs`] in two ways that matter:
246///
247/// * a new directory inherits its mode from the **parent directory**, so a
248/// subdirectory created inside a 0700 tree stays 0700 instead of being
249/// widened to a blanket 0755;
250/// * any non-explicit mode gets [`add_execute_where_read`] applied, so a
251/// directory is always traversable by whoever can read it.
252///
253/// An explicit `--mode` is obeyed verbatim, including a mode with no execute
254/// bits: the caller asked for it, and silently "fixing" an explicit request is
255/// worse than honouring a strange one.
256pub fn resolve_dir_attrs(
257 overrides: &AttrOverrides,
258 replacing: Option<&FileEntry>,
259 parent: Option<&FileEntry>,
260) -> ResolvedAttrs {
261 let (mode, mode_from) = if let Some(m) = overrides.mode {
262 (m & 0o7777, AttrSource::Explicit)
263 } else if let Some(m) = replacing.and_then(perms_of) {
264 (add_execute_where_read(m), AttrSource::Replaced)
265 } else if let Some(m) = parent.and_then(perms_of) {
266 (add_execute_where_read(m), AttrSource::Parent)
267 } else {
268 (0o755, AttrSource::Fallback)
269 };
270
271 let (uid, uid_from) = resolve_id(
272 overrides.uid,
273 replacing.and_then(|e| e.uid),
274 parent.and_then(|e| e.uid),
275 );
276 let (gid, gid_from) = resolve_id(
277 overrides.gid,
278 replacing.and_then(|e| e.gid),
279 parent.and_then(|e| e.gid),
280 );
281
282 ResolvedAttrs {
283 mode,
284 uid,
285 gid,
286 mode_from,
287 uid_from,
288 gid_from,
289 }
290}
291
292/// Shared precedence for uid and gid: explicit, then the replaced file, then
293/// the parent directory, then 0. The host file's ids are deliberately not
294/// consulted — a macOS 501:20 means nothing inside a Linux image.
295fn resolve_id(
296 explicit: Option<u32>,
297 replaced: Option<u32>,
298 parent: Option<u32>,
299) -> (u32, AttrSource) {
300 if let Some(v) = explicit {
301 (v, AttrSource::Explicit)
302 } else if let Some(v) = replaced {
303 (v, AttrSource::Replaced)
304 } else if let Some(v) = parent {
305 (v, AttrSource::Parent)
306 } else {
307 (0, AttrSource::Fallback)
308 }
309}
310
311/// Everything worth carrying from a file that is about to be replaced.
312///
313/// Replacing a file writes new *contents*; it is not a request to reset who may
314/// read it, when it was made, or what application owns it. But a replace is
315/// implemented as delete-then-create on every filesystem here, so unless these
316/// are captured first they are simply gone. Only xattrs used to be carried,
317/// which meant fixing one line of a config file silently reset its permissions,
318/// owner and timestamps.
319///
320/// Capture with [`preserved_meta`] **before** the delete, apply with
321/// [`PreservedMeta::apply_to_options`] and [`PreservedMeta::reapply_dates`].
322///
323/// Deliberately *not* preserved: the resource fork. That is content rather than
324/// metadata, and pairing a new data fork with the previous resource fork
325/// produces a file that is neither of the two things it came from.
326#[derive(Debug, Clone, Default)]
327pub struct PreservedMeta {
328 pub mode: Option<u32>,
329 pub uid: Option<u32>,
330 pub gid: Option<u32>,
331 /// Classic-Mac OSType / creator, kept raw so a non-ASCII code survives.
332 pub os_type: Option<[u8; 4]>,
333 pub os_creator: Option<[u8; 4]>,
334 /// ProDOS auxiliary type (a file's load address, say).
335 pub aux_type: Option<u16>,
336 /// FAT attribute bits: read-only, hidden, system, archive.
337 pub dos_attributes: Option<u16>,
338 pub amiga_protection: Option<u32>,
339 pub amiga_comment: Option<String>,
340 pub amiga_dates: Option<(i32, i32, i32)>,
341 /// `(created, modified, backup)` in Mac epoch seconds.
342 pub mac_dates: Option<(u32, u32, u32)>,
343 pub xattrs: Vec<crate::xattr::Xattr>,
344}
345
346impl PreservedMeta {
347 /// Whether anything was actually captured, so callers can stay quiet when
348 /// there is nothing to say.
349 pub fn is_empty(&self) -> bool {
350 self.mode.is_none()
351 && self.uid.is_none()
352 && self.gid.is_none()
353 && self.os_type.is_none()
354 && self.os_creator.is_none()
355 && self.aux_type.is_none()
356 && self.dos_attributes.is_none()
357 && self.amiga_protection.is_none()
358 && self.amiga_comment.is_none()
359 && self.amiga_dates.is_none()
360 && self.mac_dates.is_none()
361 && self.xattrs.is_empty()
362 }
363
364 /// A short, ASCII-only summary for the log line — the GUI font has no
365 /// glyph coverage beyond ASCII.
366 pub fn summary(&self) -> String {
367 let mut parts: Vec<String> = Vec::new();
368 if let Some(m) = self.mode {
369 parts.push(format!("mode {:04o}", m & 0o7777));
370 }
371 if let (Some(u), Some(g)) = (self.uid, self.gid) {
372 parts.push(format!("owner {u}:{g}"));
373 }
374 if let (Some(t), Some(c)) = (self.os_type, self.os_creator) {
375 parts.push(format!("type/creator {}/{}", ascii4(&t), ascii4(&c)));
376 }
377 if let Some(a) = self.dos_attributes {
378 parts.push(format!("DOS attrs 0x{a:02x}"));
379 }
380 if self.amiga_protection.is_some() {
381 parts.push("Amiga protection".to_string());
382 }
383 if self.mac_dates.is_some() || self.amiga_dates.is_some() {
384 parts.push("dates".to_string());
385 }
386 if !self.xattrs.is_empty() {
387 parts.push(format!("{} xattr(s)", self.xattrs.len()));
388 }
389 if parts.is_empty() {
390 "nothing to preserve".to_string()
391 } else {
392 parts.join(", ")
393 }
394 }
395
396 /// Fill a [`CreateFileOptions`] with the preserved values, leaving anything
397 /// the caller already set alone.
398 ///
399 /// An explicit `--type`/`--mode` is an instruction and must win; preserved
400 /// metadata is only the default for fields nobody spoke about.
401 pub fn apply_to_options(&self, opts: &mut crate::filesystem::CreateFileOptions) {
402 if opts.mode.is_none() {
403 opts.mode = self.mode;
404 }
405 if opts.uid.is_none() {
406 opts.uid = self.uid;
407 }
408 if opts.gid.is_none() {
409 opts.gid = self.gid;
410 }
411 // Only when the caller named neither form of type/creator, since the
412 // string and raw forms are two spellings of one field.
413 if opts.type_code.is_none() && opts.os_type.is_none() {
414 opts.os_type = self.os_type;
415 }
416 if opts.creator_code.is_none() && opts.os_creator.is_none() {
417 opts.os_creator = self.os_creator;
418 }
419 if opts.aux_type.is_none() {
420 opts.aux_type = self.aux_type;
421 }
422 if opts.dos_attributes.is_none() {
423 opts.dos_attributes = self.dos_attributes;
424 }
425 if opts.amiga_protection.is_none() {
426 opts.amiga_protection = self.amiga_protection;
427 }
428 if opts.amiga_comment.is_none() {
429 opts.amiga_comment = self.amiga_comment.clone();
430 }
431 if opts.amiga_dates.is_none() {
432 opts.amiga_dates = self.amiga_dates;
433 }
434 if opts.xattrs.is_empty() {
435 opts.xattrs = self.xattrs.clone();
436 }
437 }
438
439 /// Restore timestamps, which no filesystem here accepts at creation time.
440 ///
441 /// Best-effort by design: a filesystem with no date setter answers
442 /// `Unsupported`, and failing the whole write over a timestamp would be a
443 /// worse outcome than the file keeping today's date. Returns whether the
444 /// dates were actually applied so the caller can report honestly.
445 pub fn reapply_dates(
446 &self,
447 fs: &mut dyn crate::filesystem::EditableFilesystem,
448 entry: &FileEntry,
449 ) -> bool {
450 let Some((create, modify, backup)) = self.mac_dates else {
451 return false;
452 };
453 fs.set_dates(entry, create, modify, backup).is_ok()
454 }
455}
456
457/// Render a four-byte OSType for display, non-printables as `.`.
458fn ascii4(v: &[u8; 4]) -> String {
459 v.iter()
460 .map(|&b| {
461 if (0x20..0x7f).contains(&b) {
462 b as char
463 } else {
464 '.'
465 }
466 })
467 .collect()
468}
469
470/// Capture the metadata of a file about to be replaced.
471///
472/// Returns an empty set for a new file, which correctly means "nothing to carry
473/// over". Must be called **before** the delete: afterwards there is nothing left
474/// to ask.
475pub fn preserved_meta(
476 fs: &mut dyn crate::filesystem::Filesystem,
477 replacing: Option<&FileEntry>,
478) -> PreservedMeta {
479 let Some(entry) = replacing else {
480 return PreservedMeta::default();
481 };
482 PreservedMeta {
483 mode: entry.mode,
484 uid: entry.uid,
485 gid: entry.gid,
486 os_type: entry.type_code,
487 os_creator: entry.creator_code,
488 aux_type: entry.aux_type,
489 dos_attributes: entry.dos_attributes,
490 amiga_protection: entry.amiga_protection,
491 amiga_comment: entry.amiga_comment.clone(),
492 amiga_dates: entry.amiga_date,
493 mac_dates: entry.mac_dates,
494 xattrs: inherited_xattrs(fs, Some(entry)),
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::entry::FileEntry;
502
503 fn entry_with(mode: u32, uid: u32, gid: u32) -> FileEntry {
504 let mut e = FileEntry::new_file("f".into(), "/f".into(), 0, 0);
505 e.mode = Some(mode);
506 e.uid = Some(uid);
507 e.gid = Some(gid);
508 e
509 }
510
511 fn bare_dir() -> FileEntry {
512 FileEntry::new_directory("d".into(), "/d".into(), 0)
513 }
514
515 #[test]
516 fn explicit_overrides_win_over_everything() {
517 let replaced = entry_with(0o100_600, 7, 8);
518 let parent = entry_with(0o040_755, 9, 10);
519 let got = resolve_attrs(
520 &AttrOverrides {
521 mode: Some(0o755),
522 uid: Some(1),
523 gid: Some(2),
524 unix_times: None,
525 },
526 Some(&replaced),
527 Some(&parent),
528 Some(0o644),
529 0o644,
530 );
531 assert_eq!((got.mode, got.uid, got.gid), (0o755, 1, 2));
532 assert_eq!(got.mode_from, AttrSource::Explicit);
533 assert_eq!(got.uid_from, AttrSource::Explicit);
534 assert_eq!(got.gid_from, AttrSource::Explicit);
535 }
536
537 /// The case that matters most: replacing a file keeps its permissions and
538 /// ownership. Overwriting /etc/shadow must not widen it to 0644.
539 #[test]
540 fn replacing_a_file_inherits_its_mode_and_ownership() {
541 let shadow = entry_with(0o100_600, 0, 42);
542 let parent = entry_with(0o040_755, 0, 0);
543 let got = resolve_attrs(
544 &AttrOverrides::default(),
545 Some(&shadow),
546 Some(&parent),
547 // The host copy is a world-readable 0644 -- must NOT win.
548 Some(0o644),
549 0o644,
550 );
551 assert_eq!(got.mode, 0o600, "replaced file's mode must win over host's");
552 assert_eq!(got.uid, 0);
553 assert_eq!(got.gid, 42);
554 assert_eq!(got.mode_from, AttrSource::Replaced);
555 assert_eq!(got.gid_from, AttrSource::Replaced);
556 }
557
558 /// A genuinely new file takes ownership from the directory it lands in, and
559 /// its mode from the host file (macOS does carry those bits faithfully).
560 #[test]
561 fn new_file_takes_parent_ownership_and_host_mode() {
562 let parent = entry_with(0o040_755, 0, 20);
563 let got = resolve_attrs(
564 &AttrOverrides::default(),
565 None,
566 Some(&parent),
567 Some(0o755),
568 0o644,
569 );
570 assert_eq!(got.mode, 0o755);
571 assert_eq!(got.mode_from, AttrSource::HostFile);
572 assert_eq!(got.uid, 0);
573 assert_eq!(got.gid, 20);
574 assert_eq!(got.gid_from, AttrSource::Parent);
575 }
576
577 /// A filesystem whose entries carry no POSIX metadata at all (FAT, HFS)
578 /// must still resolve to something sane rather than panicking.
579 #[test]
580 fn falls_back_when_nothing_carries_metadata() {
581 let parent = bare_dir();
582 let got = resolve_attrs(&AttrOverrides::default(), None, Some(&parent), None, 0o644);
583 assert_eq!((got.mode, got.uid, got.gid), (0o644, 0, 0));
584 assert!(got.is_all_fallback());
585 }
586
587 /// Inheriting must strip the file-type bits: a replaced *file* entry
588 /// carries 0o100600, and only the 0o600 belongs in the new file's mode.
589 #[test]
590 fn inherited_mode_drops_the_type_bits() {
591 let replaced = entry_with(0o100_640, 0, 0);
592 let got = resolve_attrs(
593 &AttrOverrides::default(),
594 Some(&replaced),
595 None,
596 None,
597 0o644,
598 );
599 assert_eq!(got.mode, 0o640);
600 assert_eq!(got.mode & 0o170_000, 0, "type bits must not survive");
601 }
602
603 /// Setuid and sticky bits live in the 0o7777 range and must be preserved
604 /// when inheriting -- dropping the setuid bit off /bin/mount would break it.
605 #[test]
606 fn inherited_mode_keeps_setuid_and_sticky() {
607 let replaced = entry_with(0o104_755, 0, 0);
608 let got = resolve_attrs(
609 &AttrOverrides::default(),
610 Some(&replaced),
611 None,
612 None,
613 0o644,
614 );
615 assert_eq!(got.mode, 0o4755);
616 }
617
618 /// `+X` grants execute only where read already is, so it can never widen a
619 /// private directory.
620 #[test]
621 fn add_execute_where_read_never_widens_a_private_dir() {
622 // /root and ~/.ssh: owner-only, and must stay that way.
623 assert_eq!(add_execute_where_read(0o700), 0o700);
624 assert_eq!(add_execute_where_read(0o600), 0o700, "owner r -> owner x");
625 // A group-readable, other-invisible dir gains group x, not other x.
626 assert_eq!(add_execute_where_read(0o640), 0o750);
627 // Already-correct modes are untouched.
628 assert_eq!(add_execute_where_read(0o755), 0o755);
629 // Nothing readable, nothing granted.
630 assert_eq!(add_execute_where_read(0o000), 0o000);
631 // setuid/sticky bits ride along unchanged.
632 assert_eq!(add_execute_where_read(0o1644), 0o1755);
633 }
634
635 /// A new directory inside a 0700 tree must stay 0700, not become 0755.
636 #[test]
637 fn new_dir_inherits_parent_mode_rather_than_a_blanket_0755() {
638 let private = entry_with(0o040_700, 0, 0);
639 let got = resolve_dir_attrs(&AttrOverrides::default(), None, Some(&private));
640 assert_eq!(got.mode, 0o700, "a subdir of /root must not be widened");
641 assert_eq!(got.mode_from, AttrSource::Parent);
642 }
643
644 /// A directory that would otherwise be readable-but-not-enterable gets its
645 /// execute bits fixed up.
646 #[test]
647 fn new_dir_becomes_traversable_by_whoever_can_read_it() {
648 let odd = entry_with(0o040_644, 0, 0);
649 let got = resolve_dir_attrs(&AttrOverrides::default(), None, Some(&odd));
650 assert_eq!(got.mode, 0o755);
651 }
652
653 /// An explicit mode is obeyed verbatim, even a strange one -- silently
654 /// "fixing" what the caller asked for is worse than honouring it.
655 #[test]
656 fn explicit_dir_mode_is_not_second_guessed() {
657 let got = resolve_dir_attrs(
658 &AttrOverrides {
659 mode: Some(0o644),
660 ..Default::default()
661 },
662 None,
663 None,
664 );
665 assert_eq!(got.mode, 0o644);
666 assert_eq!(got.mode_from, AttrSource::Explicit);
667 }
668
669 /// One `AttrOverrides` is `Copy`, so a bulk import applies the same
670 /// decision to every member without re-deriving it per file.
671 #[test]
672 fn overrides_apply_uniformly_across_a_batch() {
673 let ov = AttrOverrides {
674 mode: Some(0o640),
675 uid: Some(0),
676 gid: Some(0),
677 unix_times: None,
678 };
679 let parents = [entry_with(0o040_755, 99, 99), entry_with(0o040_700, 5, 5)];
680 for parent in &parents {
681 let got = resolve_attrs(&ov, None, Some(parent), Some(0o777), 0o644);
682 assert_eq!((got.mode, got.uid, got.gid), (0o640, 0, 0));
683 }
684 }
685
686 /// The drivers write `CreateFileOptions::mode` straight into the inode, so
687 /// the type bits have to be there. A file created with bare `0o644` would
688 /// have no type at all.
689 #[test]
690 fn mode_helpers_carry_the_type_bits() {
691 let got = resolve_attrs(
692 &AttrOverrides {
693 mode: Some(0o640),
694 ..Default::default()
695 },
696 None,
697 None,
698 None,
699 0o644,
700 );
701 assert_eq!(got.file_mode(), 0o100_640);
702 assert_eq!(got.dir_mode(), 0o040_640);
703 // ext / UFS / EFS all default this field to 0o100644; our file mode has
704 // to be the same shape.
705 assert_eq!(got.file_mode() & 0o170_000, 0o100_000);
706 }
707
708 #[test]
709 fn describe_names_every_source() {
710 let replaced = entry_with(0o100_600, 0, 42);
711 let parent = entry_with(0o040_755, 0, 20);
712 let got = resolve_attrs(
713 &AttrOverrides {
714 uid: Some(5),
715 ..Default::default()
716 },
717 Some(&replaced),
718 Some(&parent),
719 None,
720 0o644,
721 );
722 let text = got.describe();
723 assert!(text.contains("mode 0600 (from replaced file)"), "{text}");
724 assert!(text.contains("uid 5 (explicit)"), "{text}");
725 assert!(text.contains("gid 42 (from replaced file)"), "{text}");
726 // ASCII only -- the GUI font has no glyph coverage beyond it.
727 assert!(text.is_ascii(), "log text must stay ASCII: {text}");
728 }
729}