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
30fn parse_type_settings(
31    settings: &str,
32) -> Result<(preserve::UserAndTimeSettings, Option<preserve::ModeMask>), anyhow::Error> {
33    let mut user_and_time = preserve::UserAndTimeSettings::default();
34    let mut mode_mask = None;
35    for setting in settings.split(',') {
36        match setting {
37            "uid" => user_and_time.uid = true,
38            "gid" => user_and_time.gid = true,
39            "time" => user_and_time.time = true,
40            _ => {
41                if let Ok(mask) = u32::from_str_radix(setting, 8) {
42                    mode_mask = Some(mask);
43                } else {
44                    return Err(anyhow!("Unknown preserve attribute specified: {}", setting));
45                }
46            }
47        }
48    }
49    Ok((user_and_time, mode_mask))
50}
51
52pub fn parse_preserve_settings(settings: &str) -> Result<preserve::Settings, anyhow::Error> {
53    // handle presets
54    match settings {
55        "all" => return Ok(preserve::preserve_all()),
56        "none" => return Ok(preserve::preserve_none()),
57        _ => {}
58    }
59    let mut preserve_settings = preserve::Settings::default();
60    for type_settings in settings.split_whitespace() {
61        if let Some((obj_type, obj_settings)) = type_settings.split_once(':') {
62            let (user_and_time_settings, mode_opt) = parse_type_settings(obj_settings).context(
63                format!("parsing preserve settings: {obj_settings}, type: {obj_type}"),
64            )?;
65            match obj_type {
66                "f" | "file" => {
67                    preserve_settings.file = preserve::FileSettings::default();
68                    preserve_settings.file.user_and_time = user_and_time_settings;
69                    if let Some(mode) = mode_opt {
70                        preserve_settings.file.mode_mask = mode;
71                    }
72                }
73                "d" | "dir" | "directory" => {
74                    preserve_settings.dir = preserve::DirSettings::default();
75                    preserve_settings.dir.user_and_time = user_and_time_settings;
76                    if let Some(mode) = mode_opt {
77                        preserve_settings.dir.mode_mask = mode;
78                    }
79                }
80                "l" | "link" | "symlink" => {
81                    preserve_settings.symlink = preserve::SymlinkSettings::default();
82                    preserve_settings.symlink.user_and_time = user_and_time_settings;
83                }
84                _ => {
85                    return Err(anyhow!("Unknown object type: {}", obj_type));
86                }
87            }
88        } else {
89            return Err(anyhow!("Invalid preserve settings: {}", settings));
90        }
91    }
92    Ok(preserve_settings)
93}
94
95/// Validates that every attribute checked by --update's comparison is actually being preserved.
96/// Skips size (always preserved via content copy) and ctime (kernel-managed, cannot be set).
97pub fn validate_update_compare_vs_preserve(
98    update_compare: &filecmp::MetadataCmpSettings,
99    preserve: &preserve::Settings,
100) -> Result<(), String> {
101    let mut missing = Vec::new();
102    if update_compare.mtime && !preserve.file.user_and_time.time {
103        missing.push("mtime");
104    }
105    if update_compare.uid && !preserve.file.user_and_time.uid {
106        missing.push("uid");
107    }
108    if update_compare.gid && !preserve.file.user_and_time.gid {
109        missing.push("gid");
110    }
111    // metadata_equal compares full mode (0o7777), so a partial mask is lossy
112    if update_compare.mode && preserve.file.mode_mask != 0o7777 {
113        missing.push("mode");
114    }
115    if missing.is_empty() {
116        Ok(())
117    } else {
118        Err(format!(
119            "--update compares [{}] but --preserve-settings does not preserve them. \
120             Use --allow-lossy-update to override or adjust --preserve-settings.",
121            missing.join(", ")
122        ))
123    }
124}
125
126pub fn parse_compare_settings(settings: &str) -> Result<cmp::ObjSettings, anyhow::Error> {
127    let mut cmp_settings = cmp::ObjSettings::default();
128    for type_settings in settings.split_whitespace() {
129        if let Some((obj_type, obj_settings)) = type_settings.split_once(':') {
130            let obj_cmp_settings = parse_metadata_cmp_settings(obj_settings).context(format!(
131                "parsing compare settings: {obj_settings}, type: {obj_type}"
132            ))?;
133            let obj_type = match obj_type {
134                "f" | "file" => ObjType::File,
135                "d" | "dir" | "directory" => ObjType::Dir,
136                "l" | "link" | "symlink" => ObjType::Symlink,
137                "o" | "other" => ObjType::Other,
138                _ => {
139                    return Err(anyhow!("Unknown obj type: {}", obj_type));
140                }
141            };
142            cmp_settings[obj_type] = obj_cmp_settings;
143        } else {
144            return Err(anyhow!("Invalid compare settings: {}", settings));
145        }
146    }
147    Ok(cmp_settings)
148}
149
150#[cfg(test)]
151mod parse_preserve_settings_tests {
152    use super::*;
153    #[test]
154    fn preset_all_returns_preserve_all() {
155        let settings = parse_preserve_settings("all").unwrap();
156        let expected = preserve::preserve_all();
157        assert_eq!(settings.file.mode_mask, expected.file.mode_mask);
158        assert!(settings.file.user_and_time.uid);
159        assert!(settings.file.user_and_time.gid);
160        assert!(settings.file.user_and_time.time);
161        assert_eq!(settings.dir.mode_mask, expected.dir.mode_mask);
162        assert!(settings.dir.user_and_time.uid);
163        assert!(settings.dir.user_and_time.gid);
164        assert!(settings.dir.user_and_time.time);
165        assert!(settings.symlink.user_and_time.uid);
166        assert!(settings.symlink.user_and_time.gid);
167        assert!(settings.symlink.user_and_time.time);
168    }
169    #[test]
170    fn preset_none_returns_preserve_none() {
171        let settings = parse_preserve_settings("none").unwrap();
172        let expected = preserve::preserve_none();
173        assert_eq!(settings.file.mode_mask, expected.file.mode_mask);
174        assert!(!settings.file.user_and_time.uid);
175        assert!(!settings.file.user_and_time.gid);
176        assert!(!settings.file.user_and_time.time);
177        assert_eq!(settings.dir.mode_mask, expected.dir.mode_mask);
178        assert!(!settings.dir.user_and_time.uid);
179        assert!(!settings.dir.user_and_time.gid);
180        assert!(!settings.dir.user_and_time.time);
181        assert!(!settings.symlink.user_and_time.uid);
182        assert!(!settings.symlink.user_and_time.gid);
183        assert!(!settings.symlink.user_and_time.time);
184    }
185    #[test]
186    fn per_type_settings_still_work() {
187        let settings = parse_preserve_settings("f:uid,time,0777 d:gid").unwrap();
188        assert!(settings.file.user_and_time.uid);
189        assert!(settings.file.user_and_time.time);
190        assert!(!settings.file.user_and_time.gid);
191        assert_eq!(settings.file.mode_mask, 0o777);
192        assert!(!settings.dir.user_and_time.uid);
193        assert!(settings.dir.user_and_time.gid);
194        assert!(!settings.dir.user_and_time.time);
195    }
196    #[test]
197    fn invalid_settings_returns_error() {
198        assert!(parse_preserve_settings("invalid").is_err());
199        assert!(parse_preserve_settings("f:unknown_attr").is_err());
200    }
201}
202
203#[cfg(test)]
204mod validate_update_compare_vs_preserve_tests {
205    use super::*;
206    #[test]
207    fn detects_mtime_mismatch() {
208        let compare = filecmp::MetadataCmpSettings {
209            mtime: true,
210            ..Default::default()
211        };
212        let preserve = preserve::preserve_none();
213        let result = validate_update_compare_vs_preserve(&compare, &preserve);
214        assert!(result.is_err());
215        assert!(result.unwrap_err().contains("mtime"));
216    }
217    #[test]
218    fn detects_uid_mismatch() {
219        let compare = filecmp::MetadataCmpSettings {
220            uid: true,
221            ..Default::default()
222        };
223        let preserve = preserve::preserve_none();
224        let result = validate_update_compare_vs_preserve(&compare, &preserve);
225        assert!(result.is_err());
226        assert!(result.unwrap_err().contains("uid"));
227    }
228    #[test]
229    fn detects_gid_mismatch() {
230        let compare = filecmp::MetadataCmpSettings {
231            gid: true,
232            ..Default::default()
233        };
234        let preserve = preserve::preserve_none();
235        let result = validate_update_compare_vs_preserve(&compare, &preserve);
236        assert!(result.is_err());
237        assert!(result.unwrap_err().contains("gid"));
238    }
239    #[test]
240    fn detects_mode_mismatch() {
241        let compare = filecmp::MetadataCmpSettings {
242            mode: true,
243            ..Default::default()
244        };
245        let mut preserve = preserve::preserve_none();
246        preserve.file.mode_mask = 0;
247        let result = validate_update_compare_vs_preserve(&compare, &preserve);
248        assert!(result.is_err());
249        assert!(result.unwrap_err().contains("mode"));
250    }
251    #[test]
252    fn detects_multiple_mismatches() {
253        let compare = filecmp::MetadataCmpSettings {
254            mtime: true,
255            uid: true,
256            gid: true,
257            ..Default::default()
258        };
259        let preserve = preserve::preserve_none();
260        let result = validate_update_compare_vs_preserve(&compare, &preserve);
261        assert!(result.is_err());
262        let err = result.unwrap_err();
263        assert!(err.contains("mtime"));
264        assert!(err.contains("uid"));
265        assert!(err.contains("gid"));
266    }
267    #[test]
268    fn passes_when_preserve_covers_all_compared_attrs() {
269        let compare = filecmp::MetadataCmpSettings {
270            mtime: true,
271            uid: true,
272            gid: true,
273            mode: true,
274            size: true,  // always preserved, should not cause error
275            ctime: true, // kernel-managed, should not cause error
276        };
277        let preserve = preserve::preserve_all();
278        let result = validate_update_compare_vs_preserve(&compare, &preserve);
279        assert!(result.is_ok());
280    }
281    #[test]
282    fn fails_with_partial_mode_mask_when_mode_compared() {
283        // default mode_mask is 0o0777 which drops setuid/setgid/sticky bits,
284        // but metadata_equal compares full mode (0o7777) — so this is lossy
285        let compare = filecmp::MetadataCmpSettings {
286            mode: true,
287            ..Default::default()
288        };
289        let preserve = preserve::preserve_none();
290        let result = validate_update_compare_vs_preserve(&compare, &preserve);
291        assert!(result.is_err());
292        assert!(result.unwrap_err().contains("mode"));
293    }
294}