Skip to main content

common/
settings_parse.rs

1//! Parsers for the CLI string-setting DSLs shared by the tools: `--preserve` /
2//! `--metadata-compare` / `--compare`, plus the `--update` compare-vs-preserve validation. These
3//! are pure `&str -> typed settings` functions with no dependency on the runtime/progress
4//! machinery; they are re-exported from the crate root (`common::parse_*`).
5
6use crate::cmp::{self, ObjType};
7use crate::{filecmp, preserve};
8use anyhow::{Context, anyhow};
9
10pub fn parse_metadata_cmp_settings(
11    settings: &str,
12) -> Result<filecmp::MetadataCmpSettings, anyhow::Error> {
13    let mut metadata_cmp_settings = filecmp::MetadataCmpSettings::default();
14    for setting in settings.split(',') {
15        match setting {
16            "uid" => metadata_cmp_settings.uid = true,
17            "gid" => metadata_cmp_settings.gid = true,
18            "mode" => metadata_cmp_settings.mode = true,
19            "size" => metadata_cmp_settings.size = true,
20            "mtime" => metadata_cmp_settings.mtime = true,
21            "ctime" => metadata_cmp_settings.ctime = true,
22            _ => {
23                return Err(anyhow!("Unknown metadata comparison setting: {}", setting));
24            }
25        }
26    }
27    Ok(metadata_cmp_settings)
28}
29
30/// The attributes parsed out of one `<type>:<attrs>` group of the per-type DSL.
31struct TypeSettings {
32    user_and_time: preserve::UserAndTimeSettings,
33    /// `None` when the group named no octal mask, meaning "keep the type's default mask".
34    mode_mask: Option<preserve::ModeMask>,
35    acl: bool,
36}
37
38fn parse_type_settings(settings: &str) -> Result<TypeSettings, anyhow::Error> {
39    let mut user_and_time = preserve::UserAndTimeSettings::default();
40    let mut mode_mask = None;
41    let mut acl = false;
42    for setting in settings.split(',') {
43        match setting {
44            "uid" => user_and_time.uid = true,
45            "gid" => user_and_time.gid = true,
46            "time" => user_and_time.time = true,
47            "acl" => acl = true,
48            _ => {
49                if let Ok(mask) = u32::from_str_radix(setting, 8) {
50                    mode_mask = Some(mask);
51                } else {
52                    return Err(anyhow!("Unknown preserve attribute specified: {}", setting));
53                }
54            }
55        }
56    }
57    Ok(TypeSettings {
58        user_and_time,
59        mode_mask,
60        acl,
61    })
62}
63
64/// Parse the `all` / `none` preset form, including `+`-separated modifiers such as `all+acl`.
65///
66/// Returns `Ok(None)` when `settings` is not a preset at all, which is decided *only* by its first
67/// `+`-separated token: anything other than `all` or `none` there means the caller should fall
68/// through to the per-type DSL, where `+` is an ordinary character with no special meaning.
69fn parse_preset_settings(settings: &str) -> Result<Option<preserve::Settings>, anyhow::Error> {
70    // the modifiers this loop accepts, named in the error so the two cannot drift
71    const PRESET_MODIFIERS: &str = "acl";
72    let mut tokens = settings.split('+');
73    // `split` always yields at least one item
74    let mut preset = match tokens.next().unwrap_or_default() {
75        "all" => preserve::preserve_all(),
76        "none" => preserve::preserve_none(),
77        _ => return Ok(None),
78    };
79    for modifier in tokens {
80        match modifier {
81            // repeats are idempotent, deliberately: `all+acl+acl` is not worth an error
82            "acl" => preset = preset.with_acl(true),
83            _ => {
84                // quoted, so a modifier that is empty or carries stray whitespace (`"all+acl "`
85                // out of a config file) reads as the mistake it is rather than as nonsense
86                return Err(anyhow!(
87                    "Unknown preserve preset modifier: {modifier:?}, expected one of: {PRESET_MODIFIERS}"
88                ));
89            }
90        }
91    }
92    Ok(Some(preset))
93}
94
95/// An ACL *is* the permission state - the kernel derives the mode's group bits from the ACL's
96/// `MASK` entry - so asking to preserve an ACL while masking away some of the rwx bits is a
97/// contradiction: the mask says "strip this", the ACL puts it straight back.
98///
99/// The rule is exactly `mask & 0o777 != 0o777`. Masks that strip only the *special* bits are
100/// orthogonal to the ACL (which carries no setuid/setgid/sticky) and stay legal - that includes
101/// both the shipped default `0o0777` and the `all` preset's `0o7777`.
102fn check_acl_vs_mode_mask(settings: &preserve::Settings) -> Result<(), anyhow::Error> {
103    for (obj_type, acl, mode_mask) in [
104        ("file", settings.file.acl, settings.file.mode_mask),
105        ("directory", settings.dir.acl, settings.dir.mode_mask),
106    ] {
107        if acl && mode_mask & 0o777 != 0o777 {
108            return Err(anyhow!(
109                "Preserve attribute `acl` conflicts with mode mask {mode_mask:04o} for type \
110                 `{obj_type}`: an ACL carries the rwx permission bits, so a mask that narrows them \
111                 would contradict it. Use a mask whose low 3 digits are 777 (e.g. 0777 or 7777)."
112            ));
113        }
114    }
115    Ok(())
116}
117
118/// Parse a `--preserve-settings` string.
119///
120/// Two grammars: the `all` / `none` presets with optional `+`-separated modifiers (`all+acl`), and
121/// the per-type DSL (`f:uid,gid,time,acl,7777 d:uid,gid,time l:uid,gid,time`). A string is a preset
122/// only when its first `+`-token is `all` or `none`; every other string is per-type DSL.
123pub fn parse_preserve_settings(settings: &str) -> Result<preserve::Settings, anyhow::Error> {
124    let preserve_settings = match parse_preset_settings(settings)? {
125        Some(preset) => preset,
126        None => parse_per_type_settings(settings)?,
127    };
128    check_acl_vs_mode_mask(&preserve_settings)?;
129    Ok(preserve_settings)
130}
131
132fn parse_per_type_settings(settings: &str) -> Result<preserve::Settings, anyhow::Error> {
133    let mut preserve_settings = preserve::Settings::default();
134    for type_settings in settings.split_whitespace() {
135        if let Some((obj_type, obj_settings)) = type_settings.split_once(':') {
136            let parsed = parse_type_settings(obj_settings).context(format!(
137                "parsing preserve settings: {obj_settings}, type: {obj_type}"
138            ))?;
139            match obj_type {
140                "f" | "file" => {
141                    preserve_settings.file = preserve::FileSettings::default();
142                    preserve_settings.file.user_and_time = parsed.user_and_time;
143                    preserve_settings.file.acl = parsed.acl;
144                    if let Some(mode) = parsed.mode_mask {
145                        preserve_settings.file.mode_mask = mode;
146                    }
147                }
148                "d" | "dir" | "directory" => {
149                    preserve_settings.dir = preserve::DirSettings::default();
150                    preserve_settings.dir.user_and_time = parsed.user_and_time;
151                    preserve_settings.dir.acl = parsed.acl;
152                    if let Some(mode) = parsed.mode_mask {
153                        preserve_settings.dir.mode_mask = mode;
154                    }
155                }
156                "l" | "link" | "symlink" => {
157                    // accepting `l:acl` and quietly doing nothing would be the same silent lie ACL
158                    // preservation exists to remove, so it is an error instead
159                    if parsed.acl {
160                        return Err(anyhow!(
161                            "Preserve attribute `acl` is not valid for type `{obj_type}`: symlinks \
162                             cannot carry ACLs (the kernel has no symlink ACL)"
163                        ));
164                    }
165                    preserve_settings.symlink = preserve::SymlinkSettings::default();
166                    preserve_settings.symlink.user_and_time = parsed.user_and_time;
167                }
168                _ => {
169                    return Err(anyhow!("Unknown object type: {}", obj_type));
170                }
171            }
172        } else {
173            return Err(anyhow!("Invalid preserve settings: {}", settings));
174        }
175    }
176    Ok(preserve_settings)
177}
178
179/// Validates that every attribute checked by --update's comparison is actually being preserved.
180/// Skips size (always preserved via content copy) and ctime (kernel-managed, cannot be set).
181pub fn validate_update_compare_vs_preserve(
182    update_compare: &filecmp::MetadataCmpSettings,
183    preserve: &preserve::Settings,
184) -> Result<(), String> {
185    let mut missing = Vec::new();
186    if update_compare.mtime && !preserve.file.user_and_time.time {
187        missing.push("mtime");
188    }
189    if update_compare.uid && !preserve.file.user_and_time.uid {
190        missing.push("uid");
191    }
192    if update_compare.gid && !preserve.file.user_and_time.gid {
193        missing.push("gid");
194    }
195    // metadata_equal compares full mode (0o7777), so a partial mask is lossy
196    if update_compare.mode && preserve.file.mode_mask != 0o7777 {
197        missing.push("mode");
198    }
199    if missing.is_empty() {
200        Ok(())
201    } else {
202        Err(format!(
203            "--update compares [{}] but --preserve-settings does not preserve them. \
204             Use --allow-lossy-update to override or adjust --preserve-settings.",
205            missing.join(", ")
206        ))
207    }
208}
209
210pub fn parse_compare_settings(settings: &str) -> Result<cmp::ObjSettings, anyhow::Error> {
211    let mut cmp_settings = cmp::ObjSettings::default();
212    for type_settings in settings.split_whitespace() {
213        if let Some((obj_type, obj_settings)) = type_settings.split_once(':') {
214            let obj_cmp_settings = parse_metadata_cmp_settings(obj_settings).context(format!(
215                "parsing compare settings: {obj_settings}, type: {obj_type}"
216            ))?;
217            let obj_type = match obj_type {
218                "f" | "file" => ObjType::File,
219                "d" | "dir" | "directory" => ObjType::Dir,
220                "l" | "link" | "symlink" => ObjType::Symlink,
221                "o" | "other" => ObjType::Other,
222                _ => {
223                    return Err(anyhow!("Unknown obj type: {}", obj_type));
224                }
225            };
226            cmp_settings[obj_type] = obj_cmp_settings;
227        } else {
228            return Err(anyhow!("Invalid compare settings: {}", settings));
229        }
230    }
231    Ok(cmp_settings)
232}
233
234#[cfg(test)]
235mod parse_preserve_settings_tests {
236    use super::*;
237    #[test]
238    fn preset_all_returns_preserve_all() {
239        let settings = parse_preserve_settings("all").unwrap();
240        let expected = preserve::preserve_all();
241        assert_eq!(settings.file.mode_mask, expected.file.mode_mask);
242        assert!(settings.file.user_and_time.uid);
243        assert!(settings.file.user_and_time.gid);
244        assert!(settings.file.user_and_time.time);
245        assert_eq!(settings.dir.mode_mask, expected.dir.mode_mask);
246        assert!(settings.dir.user_and_time.uid);
247        assert!(settings.dir.user_and_time.gid);
248        assert!(settings.dir.user_and_time.time);
249        assert!(settings.symlink.user_and_time.uid);
250        assert!(settings.symlink.user_and_time.gid);
251        assert!(settings.symlink.user_and_time.time);
252    }
253    #[test]
254    fn preset_none_returns_preserve_none() {
255        let settings = parse_preserve_settings("none").unwrap();
256        let expected = preserve::preserve_none();
257        assert_eq!(settings.file.mode_mask, expected.file.mode_mask);
258        assert!(!settings.file.user_and_time.uid);
259        assert!(!settings.file.user_and_time.gid);
260        assert!(!settings.file.user_and_time.time);
261        assert_eq!(settings.dir.mode_mask, expected.dir.mode_mask);
262        assert!(!settings.dir.user_and_time.uid);
263        assert!(!settings.dir.user_and_time.gid);
264        assert!(!settings.dir.user_and_time.time);
265        assert!(!settings.symlink.user_and_time.uid);
266        assert!(!settings.symlink.user_and_time.gid);
267        assert!(!settings.symlink.user_and_time.time);
268    }
269    #[test]
270    fn per_type_settings_still_work() {
271        let settings = parse_preserve_settings("f:uid,time,0777 d:gid").unwrap();
272        assert!(settings.file.user_and_time.uid);
273        assert!(settings.file.user_and_time.time);
274        assert!(!settings.file.user_and_time.gid);
275        assert_eq!(settings.file.mode_mask, 0o777);
276        assert!(!settings.dir.user_and_time.uid);
277        assert!(settings.dir.user_and_time.gid);
278        assert!(!settings.dir.user_and_time.time);
279    }
280    #[test]
281    fn invalid_settings_returns_error() {
282        assert!(parse_preserve_settings("invalid").is_err());
283        assert!(parse_preserve_settings("f:unknown_attr").is_err());
284    }
285    #[test]
286    fn preset_all_does_not_enable_acls() {
287        // decision D2: ACLs cost a syscall per entry to even detect, so `all` must not opt in
288        let settings = parse_preserve_settings("all").unwrap();
289        assert!(!settings.file.acl);
290        assert!(!settings.dir.acl);
291    }
292    #[test]
293    fn preset_all_plus_acl_enables_acls_on_files_and_dirs() {
294        let settings = parse_preserve_settings("all+acl").unwrap();
295        assert!(settings.file.acl);
296        assert!(settings.dir.acl);
297        // symlinks carry no ACL at all, so `SymlinkSettings` has no field to set; the rest of the
298        // `all` preset must still reach them
299        assert!(settings.symlink.user_and_time.uid);
300        assert!(settings.symlink.user_and_time.gid);
301        assert!(settings.symlink.user_and_time.time);
302        // and the modifier must not disturb the preset it modifies
303        let expected = preserve::preserve_all_with_acls();
304        assert_eq!(settings.file.mode_mask, expected.file.mode_mask);
305        assert_eq!(settings.dir.mode_mask, expected.dir.mode_mask);
306        assert!(settings.file.user_and_time.time);
307        assert!(settings.dir.user_and_time.time);
308    }
309    #[test]
310    fn preset_none_plus_acl_enables_only_acls() {
311        // odd but coherent: copy ACLs without ownership or timestamps
312        let settings = parse_preserve_settings("none+acl").unwrap();
313        assert!(settings.file.acl);
314        assert!(settings.dir.acl);
315        assert!(!settings.file.user_and_time.any());
316        assert!(!settings.dir.user_and_time.any());
317        assert!(!settings.symlink.user_and_time.any());
318        assert_eq!(
319            settings.file.mode_mask,
320            preserve::preserve_none().file.mode_mask
321        );
322    }
323    #[test]
324    fn repeated_preset_modifier_is_idempotent() {
325        let settings = parse_preserve_settings("all+acl+acl").unwrap();
326        assert!(settings.file.acl);
327        assert!(settings.dir.acl);
328    }
329    #[test]
330    fn unknown_preset_modifier_names_the_valid_set() {
331        let err = parse_preserve_settings("all+xattr")
332            .unwrap_err()
333            .to_string();
334        assert!(err.contains("xattr"), "{err}");
335        assert!(err.contains("acl"), "{err}");
336        assert!(parse_preserve_settings("none+bogus").is_err());
337        // an empty modifier is not silently accepted either
338        assert!(parse_preserve_settings("all+").is_err());
339    }
340    #[test]
341    fn plus_stays_an_ordinary_character_in_the_per_type_dsl() {
342        // `+` is only special when the FIRST token is a preset name; `u32::from_str_radix` accepts a
343        // leading `+`, so this string parses as a plain mode mask today and must keep doing so
344        let settings = parse_preserve_settings("f:+777").unwrap();
345        assert_eq!(settings.file.mode_mask, 0o777);
346        assert!(!settings.file.acl);
347        // and a `+`-containing string that was a per-type-DSL error stays that error, rather than
348        // being reinterpreted as a preset modifier
349        let err = parse_preserve_settings("f:uid+d:gid")
350            .unwrap_err()
351            .to_string();
352        assert!(!err.contains("preset modifier"), "{err}");
353    }
354    #[test]
355    fn per_type_acl_attribute_parses() {
356        let settings =
357            parse_preserve_settings("f:uid,gid,time,acl,7777 d:uid,gid,time,acl,7777").unwrap();
358        assert!(settings.file.acl);
359        assert_eq!(settings.file.mode_mask, 0o7777);
360        assert!(settings.file.user_and_time.uid);
361        assert!(settings.dir.acl);
362        assert_eq!(settings.dir.mode_mask, 0o7777);
363        // a type not named keeps its default, ACLs off
364        assert!(!settings.symlink.user_and_time.any());
365    }
366    #[test]
367    fn acl_on_symlinks_is_rejected() {
368        for spec in ["l:acl", "link:acl", "symlink:acl", "l:uid,acl"] {
369            let err = parse_preserve_settings(spec).unwrap_err().to_string();
370            assert!(err.contains("acl"), "{spec}: {err}");
371            assert!(err.contains("symlink"), "{spec}: {err}");
372        }
373    }
374    #[test]
375    fn acl_with_a_mask_that_narrows_rwx_is_rejected() {
376        for spec in [
377            "f:acl,0700", // strips group and other
378            "f:acl,0770", // strips other only
379            "f:acl,7000", // strips all rwx
380            "d:acl,0755", // dirs are checked too
381            "f:acl,0777 d:acl,0700",
382        ] {
383            let err = parse_preserve_settings(spec).unwrap_err().to_string();
384            assert!(err.contains("acl"), "{spec}: {err}");
385            assert!(err.contains("mode mask"), "{spec}: {err}");
386        }
387    }
388    #[test]
389    fn acl_with_a_mask_that_only_strips_special_bits_is_accepted() {
390        // the ACL carries no setuid/setgid/sticky, so those bits are orthogonal to it
391        assert!(parse_preserve_settings("f:acl,7777").unwrap().file.acl);
392        assert!(parse_preserve_settings("f:acl,0777").unwrap().file.acl);
393        // bare `f:acl` inherits the shipped default mask 0o0777, which must also pass
394        let bare = parse_preserve_settings("f:acl").unwrap();
395        assert!(bare.file.acl);
396        assert!(!bare.dir.acl);
397        assert_eq!(bare.file.mode_mask, 0o0777);
398        // as must both presets
399        assert!(parse_preserve_settings("all+acl").is_ok());
400        assert!(parse_preserve_settings("none+acl").is_ok());
401    }
402    #[test]
403    fn per_type_acl_applies_only_to_the_named_type() {
404        // `acl` leaking from one type to the other is decision D2's cost charged to a user who did
405        // not ask for it: `f:acl` enabling directory ACLs spends 2 extra syscalls per directory
406        let file_only = parse_preserve_settings("f:acl").unwrap();
407        assert!(file_only.file.acl);
408        assert!(!file_only.dir.acl);
409        let dir_only = parse_preserve_settings("d:acl").unwrap();
410        assert!(dir_only.dir.acl);
411        assert!(!dir_only.file.acl);
412        // and naming a type without `acl` alongside one with it must not pick it up
413        let mixed = parse_preserve_settings("f:acl d:uid,gid").unwrap();
414        assert!(mixed.file.acl);
415        assert!(!mixed.dir.acl);
416    }
417    #[test]
418    fn narrowing_mask_without_acl_is_still_accepted() {
419        // the rejection is about the combination; masks alone are unconstrained
420        assert_eq!(
421            parse_preserve_settings("f:uid,0700")
422                .unwrap()
423                .file
424                .mode_mask,
425            0o700
426        );
427    }
428}
429
430#[cfg(test)]
431mod validate_update_compare_vs_preserve_tests {
432    use super::*;
433    #[test]
434    fn detects_mtime_mismatch() {
435        let compare = filecmp::MetadataCmpSettings {
436            mtime: true,
437            ..Default::default()
438        };
439        let preserve = preserve::preserve_none();
440        let result = validate_update_compare_vs_preserve(&compare, &preserve);
441        assert!(result.is_err());
442        assert!(result.unwrap_err().contains("mtime"));
443    }
444    #[test]
445    fn detects_uid_mismatch() {
446        let compare = filecmp::MetadataCmpSettings {
447            uid: true,
448            ..Default::default()
449        };
450        let preserve = preserve::preserve_none();
451        let result = validate_update_compare_vs_preserve(&compare, &preserve);
452        assert!(result.is_err());
453        assert!(result.unwrap_err().contains("uid"));
454    }
455    #[test]
456    fn detects_gid_mismatch() {
457        let compare = filecmp::MetadataCmpSettings {
458            gid: true,
459            ..Default::default()
460        };
461        let preserve = preserve::preserve_none();
462        let result = validate_update_compare_vs_preserve(&compare, &preserve);
463        assert!(result.is_err());
464        assert!(result.unwrap_err().contains("gid"));
465    }
466    #[test]
467    fn detects_mode_mismatch() {
468        let compare = filecmp::MetadataCmpSettings {
469            mode: true,
470            ..Default::default()
471        };
472        let mut preserve = preserve::preserve_none();
473        preserve.file.mode_mask = 0;
474        let result = validate_update_compare_vs_preserve(&compare, &preserve);
475        assert!(result.is_err());
476        assert!(result.unwrap_err().contains("mode"));
477    }
478    #[test]
479    fn detects_multiple_mismatches() {
480        let compare = filecmp::MetadataCmpSettings {
481            mtime: true,
482            uid: true,
483            gid: true,
484            ..Default::default()
485        };
486        let preserve = preserve::preserve_none();
487        let result = validate_update_compare_vs_preserve(&compare, &preserve);
488        assert!(result.is_err());
489        let err = result.unwrap_err();
490        assert!(err.contains("mtime"));
491        assert!(err.contains("uid"));
492        assert!(err.contains("gid"));
493    }
494    #[test]
495    fn passes_when_preserve_covers_all_compared_attrs() {
496        let compare = filecmp::MetadataCmpSettings {
497            mtime: true,
498            uid: true,
499            gid: true,
500            mode: true,
501            size: true,  // always preserved, should not cause error
502            ctime: true, // kernel-managed, should not cause error
503        };
504        let preserve = preserve::preserve_all();
505        let result = validate_update_compare_vs_preserve(&compare, &preserve);
506        assert!(result.is_ok());
507    }
508    #[test]
509    fn fails_with_partial_mode_mask_when_mode_compared() {
510        // default mode_mask is 0o0777 which drops setuid/setgid/sticky bits,
511        // but metadata_equal compares full mode (0o7777) — so this is lossy
512        let compare = filecmp::MetadataCmpSettings {
513            mode: true,
514            ..Default::default()
515        };
516        let preserve = preserve::preserve_none();
517        let result = validate_update_compare_vs_preserve(&compare, &preserve);
518        assert!(result.is_err());
519        assert!(result.unwrap_err().contains("mode"));
520    }
521}