Skip to main content

common/
preserve.rs

1use serde::{Deserialize, Serialize};
2use std::os::unix::fs::MetadataExt;
3use std::os::unix::prelude::PermissionsExt;
4
5pub trait Metadata {
6    fn uid(&self) -> u32;
7    fn gid(&self) -> u32;
8    fn atime(&self) -> i64;
9    fn atime_nsec(&self) -> i64;
10    fn mtime(&self) -> i64;
11    fn mtime_nsec(&self) -> i64;
12    fn permissions(&self) -> std::fs::Permissions;
13    // ctime cannot be set manually, but we include it for comparison purposes
14    // default implementation returns 0 to indicate ctime is not available (e.g., in protocol::Metadata)
15    fn ctime(&self) -> i64 {
16        0
17    }
18    fn ctime_nsec(&self) -> i64 {
19        0
20    }
21    // size is not preserved (cannot be set), but included for comparison purposes
22    // default implementation returns 0 to indicate size is not available or not applicable
23    fn size(&self) -> u64 {
24        0
25    }
26}
27
28impl Metadata for std::fs::Metadata {
29    fn uid(&self) -> u32 {
30        MetadataExt::uid(self)
31    }
32    fn gid(&self) -> u32 {
33        MetadataExt::gid(self)
34    }
35    fn atime(&self) -> i64 {
36        MetadataExt::atime(self)
37    }
38    fn atime_nsec(&self) -> i64 {
39        MetadataExt::atime_nsec(self)
40    }
41    fn mtime(&self) -> i64 {
42        MetadataExt::mtime(self)
43    }
44    fn mtime_nsec(&self) -> i64 {
45        MetadataExt::mtime_nsec(self)
46    }
47    fn permissions(&self) -> std::fs::Permissions {
48        self.permissions()
49    }
50    fn ctime(&self) -> i64 {
51        MetadataExt::ctime(self)
52    }
53    fn ctime_nsec(&self) -> i64 {
54        MetadataExt::ctime_nsec(self)
55    }
56    fn size(&self) -> u64 {
57        self.len()
58    }
59}
60
61#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
62pub struct UserAndTimeSettings {
63    pub uid: bool,
64    pub gid: bool,
65    pub time: bool,
66}
67
68impl UserAndTimeSettings {
69    #[must_use]
70    pub fn any(&self) -> bool {
71        self.uid || self.gid || self.time
72    }
73}
74
75pub type ModeMask = u32;
76
77#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
78pub struct FileSettings {
79    pub user_and_time: UserAndTimeSettings,
80    pub mode_mask: ModeMask,
81    /// Preserve the POSIX.1e access ACL (`system.posix_acl_access`).
82    ///
83    /// Opt-in, and deliberately not part of [`preserve_all`] — see the comment there.
84    pub acl: bool,
85}
86
87impl Default for FileSettings {
88    fn default() -> Self {
89        Self {
90            user_and_time: UserAndTimeSettings::default(),
91            mode_mask: 0o0777, // remove sticky bit, setuid and setgid to mimic "cp" tool
92            acl: false,
93        }
94    }
95}
96
97#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
98pub struct DirSettings {
99    pub user_and_time: UserAndTimeSettings,
100    pub mode_mask: ModeMask,
101    /// Preserve the POSIX.1e access *and* default ACLs (`system.posix_acl_access` and
102    /// `system.posix_acl_default`).
103    ///
104    /// Opt-in, and deliberately not part of [`preserve_all`] — see the comment there.
105    pub acl: bool,
106}
107
108impl Default for DirSettings {
109    fn default() -> Self {
110        Self {
111            user_and_time: UserAndTimeSettings::default(),
112            mode_mask: 0o0777,
113            acl: false,
114        }
115    }
116}
117
118/// Symlink preserve settings.
119///
120/// There is deliberately no `acl` field: the kernel has no symlink ACL, so asking to preserve one
121/// is a request that could only ever be silently ignored. The settings parser rejects `l:acl`
122/// instead.
123#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
124pub struct SymlinkSettings {
125    pub user_and_time: UserAndTimeSettings,
126}
127
128impl SymlinkSettings {
129    #[must_use]
130    pub fn any(&self) -> bool {
131        self.user_and_time.any()
132    }
133}
134
135#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
136pub struct Settings {
137    pub file: FileSettings,
138    pub dir: DirSettings,
139    pub symlink: SymlinkSettings,
140}
141
142impl Settings {
143    /// Turn ACL preservation on (or off) for files and directories.
144    ///
145    /// Symlinks are untouched — see [`SymlinkSettings`]. This is the single place that knows which
146    /// object types can carry an ACL, so the `all+acl` preset, the `none+acl` preset and
147    /// [`preserve_all_with_acls`] cannot drift apart.
148    #[must_use]
149    pub fn with_acl(mut self, acl: bool) -> Self {
150        self.file.acl = acl;
151        self.dir.acl = acl;
152        self
153    }
154
155    /// Whether this run asks for any metadata fidelity beyond what the tools ship with.
156    ///
157    /// The shipped default reproduces the source's `rwx` bits like `cp` and nothing else — no uid,
158    /// no gid, no timestamps, no setuid/setgid/sticky. A run that stays there is not making a claim
159    /// about the destination's metadata, so advice about metadata it also does not carry (see
160    /// [`crate::safedir::RootAclNotice`]) is noise. Asking for *anything* more — `--preserve`,
161    /// `all`, a bare `f:uid`, a `7777` mode mask — is that claim, and the advice becomes worth
162    /// printing.
163    ///
164    /// Compared as a whole value rather than field by field, so a future field is covered the
165    /// moment it exists. It reads the RESOLVED settings, so `--preserve-settings=none` — and any
166    /// spelling that lands back on the default, such as `f:0777 d:0777` — counts as "did not ask",
167    /// which is what those settings mean.
168    #[must_use]
169    pub fn requests_preservation(&self) -> bool {
170        *self != Self::default()
171    }
172}
173
174/// Compute the permission bits to apply, honoring the mode mask.
175///
176/// When `mode_mask == 0o7777` (the "preserve everything" case) the source mode
177/// is returned verbatim, including setuid/setgid/sticky bits. Otherwise the mode
178/// is masked with `mode_mask` (e.g. the default `0o0777` strips the special
179/// bits, mimicking `cp`). The returned value is always confined to the
180/// permission bits (`0o7777`); file-type bits are never included.
181///
182/// This is the single source of truth for the mode the fd-based metadata appliers in
183/// `crate::safedir` apply. It does NOT decide the mode a destination is *created* at: destinations
184/// are always created owner-only ([`crate::safedir::DST_FILE_CREATE_MODE`] /
185/// [`crate::safedir::DST_DIR_CREATE_MODE`]) and only widened to this value once their contents are
186/// written. Files and directories share this logic; pass the relevant `mode_mask`
187/// ([`FileSettings::mode_mask`] or [`DirSettings::mode_mask`]).
188#[must_use]
189pub fn masked_mode<Meta: Metadata>(mode_mask: ModeMask, metadata: &Meta) -> u32 {
190    // confine to permission bits up front so a user-supplied mask that itself includes file-type
191    // (S_IF*) bits can never leak them into the create-mode / chmod target (`--preserve` parses the
192    // mask as an arbitrary octal, with no upper bound)
193    let mode = metadata.permissions().mode() & 0o7777;
194    if mode_mask == 0o7777 {
195        // default preserve keeps all permission bits verbatim
196        mode
197    } else {
198        mode & mode_mask
199    }
200}
201
202/// Preserve uid, gid, timestamps and the full mode (including setuid/setgid/sticky).
203///
204/// This does **not** preserve ACLs; use [`preserve_all_with_acls`], equivalent to the `all+acl`
205/// settings string, for that.
206#[must_use]
207pub fn preserve_all() -> Settings {
208    let user_and_time = UserAndTimeSettings {
209        uid: true,
210        gid: true,
211        time: true,
212    };
213    Settings {
214        file: FileSettings {
215            user_and_time,
216            mode_mask: 0o7777,
217            // `all` deliberately does NOT preserve ACLs, and this stays spelled out rather than
218            // riding on `..Default::default()`. There is no bit in `stat` saying whether an entry
219            // has an ACL, so merely finding out costs an extra syscall on every single entry -
220            // roughly doubling the per-entry metadata cost of the flag most people reach for by
221            // default. ACLs are therefore opt-in via `all+acl` / `preserve_all_with_acls`.
222            acl: false,
223        },
224        dir: DirSettings {
225            user_and_time,
226            mode_mask: 0o7777,
227            // see the file settings above: opt-in for the same per-entry cost reason (and
228            // directories pay for two ACLs, access and default)
229            acl: false,
230        },
231        symlink: SymlinkSettings { user_and_time },
232    }
233}
234
235/// [`preserve_all`] plus POSIX ACLs on files and directories, equivalent to the `all+acl` settings
236/// string.
237///
238/// The settings parser does not call this — it applies the `+acl` modifier generically to whichever
239/// preset it saw. The two agree because both route through [`Settings::with_acl`].
240#[must_use]
241pub fn preserve_all_with_acls() -> Settings {
242    preserve_all().with_acl(true)
243}
244
245#[must_use]
246pub fn preserve_none() -> Settings {
247    Settings::default()
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::os::unix::fs::PermissionsExt;
254    struct FakeMeta {
255        mode: u32,
256    }
257    impl Metadata for FakeMeta {
258        fn uid(&self) -> u32 {
259            0
260        }
261        fn gid(&self) -> u32 {
262            0
263        }
264        fn atime(&self) -> i64 {
265            0
266        }
267        fn atime_nsec(&self) -> i64 {
268            0
269        }
270        fn mtime(&self) -> i64 {
271            0
272        }
273        fn mtime_nsec(&self) -> i64 {
274            0
275        }
276        fn permissions(&self) -> std::fs::Permissions {
277            std::fs::Permissions::from_mode(self.mode)
278        }
279    }
280    #[test]
281    fn default_mask_strips_setuid_setgid_sticky() {
282        // default mode_mask is 0o0777, which must drop the special bits like `cp`
283        let meta = FakeMeta { mode: 0o7755 };
284        assert_eq!(masked_mode(0o0777, &meta), 0o0755);
285    }
286    #[test]
287    fn full_mask_preserves_special_bits_verbatim() {
288        // 0o7777 is the "preserve everything" sentinel: setuid/setgid/sticky survive
289        let meta = FakeMeta { mode: 0o4755 };
290        assert_eq!(masked_mode(0o7777, &meta), 0o4755);
291    }
292    #[test]
293    fn masking_strips_file_type_bits() {
294        // source mode carries S_IFREG (0o100000); only permission bits may be returned
295        let meta = FakeMeta { mode: 0o100644 };
296        assert_eq!(masked_mode(0o7777, &meta), 0o0644);
297        assert_eq!(masked_mode(0o0777, &meta), 0o0644);
298    }
299    #[test]
300    fn out_of_range_mask_cannot_leak_file_type_bits() {
301        // --preserve parses the mask as an arbitrary octal u32 (no upper bound), so a mask that
302        // itself includes S_IF* bits must still not leak them into the returned mode
303        let meta = FakeMeta { mode: 0o100644 };
304        assert_eq!(masked_mode(0o100777, &meta), 0o0644);
305    }
306    #[test]
307    fn custom_mask_applies() {
308        let meta = FakeMeta { mode: 0o0777 };
309        assert_eq!(masked_mode(0o0700, &meta), 0o0700);
310    }
311    #[test]
312    fn shipped_defaults_strip_special_bits_while_preserve_all_keeps_them() {
313        // pin the settings the tools ship with so a default change can't silently weaken fidelity
314        assert_eq!(FileSettings::default().mode_mask, 0o0777);
315        assert_eq!(DirSettings::default().mode_mask, 0o0777);
316        let all = preserve_all();
317        assert_eq!(all.file.mode_mask, 0o7777);
318        assert_eq!(all.dir.mode_mask, 0o7777);
319    }
320    #[test]
321    fn shipped_defaults_and_preserve_all_leave_acls_off() {
322        // ACLs are opt-in because detecting one costs a syscall per entry; a silent flip here would
323        // impose that cost on every default copy
324        assert!(!FileSettings::default().acl);
325        assert!(!DirSettings::default().acl);
326        let all = preserve_all();
327        assert!(!all.file.acl);
328        assert!(!all.dir.acl);
329        let none = preserve_none();
330        assert!(!none.file.acl);
331        assert!(!none.dir.acl);
332    }
333    #[test]
334    fn preserve_all_with_acls_enables_acls_without_changing_anything_else() {
335        // whole-value equality, so any extra field `with_acl` touches shows up here rather than
336        // slipping past a hand-written list of assertions
337        let mut expected = preserve_all();
338        expected.file.acl = true;
339        expected.dir.acl = true;
340        assert_eq!(preserve_all_with_acls(), expected);
341    }
342    #[test]
343    fn with_acl_touches_only_the_acl_fields() {
344        let mut expected = preserve_none();
345        expected.file.acl = true;
346        expected.dir.acl = true;
347        assert_eq!(preserve_none().with_acl(true), expected);
348    }
349    #[test]
350    fn with_acl_is_idempotent_and_reversible() {
351        assert_eq!(
352            preserve_none().with_acl(true).with_acl(true),
353            preserve_none().with_acl(true)
354        );
355        assert_eq!(preserve_all_with_acls().with_acl(false), preserve_all());
356    }
357    #[test]
358    fn the_shipped_default_requests_no_preservation() {
359        // `none` IS the shipped default, so both spellings of "I did not ask" agree
360        assert!(!Settings::default().requests_preservation());
361        assert!(!preserve_none().requests_preservation());
362    }
363    #[test]
364    fn any_attribute_beyond_the_default_requests_preservation() {
365        // one field at a time, so a predicate that happened to key off only `uid` (or only the
366        // mode mask) fails here rather than in whatever consumes it
367        for settings in [
368            preserve_all(),
369            preserve_all_with_acls(),
370            preserve_none().with_acl(true),
371            Settings {
372                file: FileSettings {
373                    user_and_time: UserAndTimeSettings {
374                        uid: true,
375                        ..Default::default()
376                    },
377                    ..Default::default()
378                },
379                ..Default::default()
380            },
381            Settings {
382                dir: DirSettings {
383                    user_and_time: UserAndTimeSettings {
384                        time: true,
385                        ..Default::default()
386                    },
387                    ..Default::default()
388                },
389                ..Default::default()
390            },
391            Settings {
392                symlink: SymlinkSettings {
393                    user_and_time: UserAndTimeSettings {
394                        gid: true,
395                        ..Default::default()
396                    },
397                },
398                ..Default::default()
399            },
400            Settings {
401                file: FileSettings {
402                    mode_mask: 0o7777,
403                    ..Default::default()
404                },
405                ..Default::default()
406            },
407        ] {
408            assert!(
409                settings.requests_preservation(),
410                "{settings:?} asks for more than the shipped default but reads as 'did not ask'"
411            );
412        }
413    }
414    #[test]
415    fn settings_that_spell_out_the_default_request_no_preservation() {
416        // the predicate reads the RESOLVED value, so writing the default out longhand is the same
417        // as not writing it at all — otherwise `f:0777 d:0777` would behave unlike a bare run
418        let spelled_out = Settings {
419            file: FileSettings {
420                mode_mask: 0o0777,
421                ..Default::default()
422            },
423            dir: DirSettings {
424                mode_mask: 0o0777,
425                ..Default::default()
426            },
427            symlink: SymlinkSettings::default(),
428        };
429        assert!(!spelled_out.requests_preservation());
430    }
431}