Skip to main content

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}