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}