dev_scripts/sync/mirror/
rsync_changes.rs

1use std::{ffi::OsStr, os::unix::ffi::OsStrExt, path::Path};
2
3use anyhow::{Result, anyhow};
4use winnow::{
5    Parser,
6    combinator::{alt, eof, preceded, repeat, seq},
7    token::{one_of, rest},
8};
9
10/// Kind of change that was made to a path.
11///
12/// See the [`--itemize-changes` section in `man 1 rsync`](https://man.archlinux.org/man/rsync.1#itemize-changes).
13/// This type corresponds to the `Y` placeholder in the format.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(crate) enum ChangeKind {
16    /// A file is being transferred to the remote host.
17    Sent,
18    /// A file is being transferred to the local host.
19    Received,
20    /// A local change is occurring for the item.
21    LocalChange,
22    /// Item is a hard link to another item.
23    Hardlink,
24    /// The item is not being updated, but its attributes may be modified.
25    NotUpdated,
26}
27
28/// Kind of path that a change was made to.
29///
30/// The `rsync` invocation in this crate should only emit [`PathKind::File`].
31/// Any other values are unexpected and considered errors.
32///
33/// See the [`--itemize-changes` section in `man 1 rsync`](https://man.archlinux.org/man/rsync.1#itemize-changes).
34/// This type corresponds to the `X` placeholder in the format.
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub(crate) enum PathKind {
37    /// A file is changed.
38    File,
39    /// A directory is changed.
40    Directory,
41    /// A symlink is changed.
42    Symlink,
43    /// A device is changed.
44    Device,
45    /// A special file is changed.
46    ///
47    /// This can include named sockets and FIFOs.
48    SpecialFile,
49}
50
51/// Change information reported by `rsync`.
52///
53/// See the [`--itemize-changes` section in `man 1 rsync`](https://man.archlinux.org/man/rsync.1#itemize-changes).
54#[derive(Clone, Debug)]
55pub(crate) enum Report<'a> {
56    /// `rsync` message string.
57    Message(&'a OsStr),
58    /// Changes have been made to a local path.
59    PathChange {
60        /// Kind of change that is being made.
61        change_kind: ChangeKind,
62        /// Kind of path that is being changed.
63        path_kind: PathKind,
64        /// [`Path`] to the item in question.
65        path: &'a Path,
66    },
67    /// `rsync` did not report any changes.
68    Empty,
69}
70
71impl<'a> Report<'a> {
72    /// Parse a [`Self`] from one line of `rsync --itemize-changes`.
73    ///
74    /// Callers should be careful to remove trailing newlines from `rsync` output
75    /// as this function will *not* remove them.
76    ///
77    /// For accepted inputs, see the
78    /// [`--itemize-changes` section in `man 1 rsync`](https://man.archlinux.org/man/rsync.1#itemize-changes).
79    ///
80    /// # Errors
81    ///
82    /// Throws an error if `input` is of an unknown format
83    pub(crate) fn parser(mut line: &'a [u8]) -> winnow::ModalResult<Self> {
84        let line = &mut line;
85        let mut change_kind = alt((
86            b'>'.value(ChangeKind::Received),
87            b'c'.value(ChangeKind::LocalChange),
88            b'h'.value(ChangeKind::Hardlink),
89            b'<'.value(ChangeKind::Sent),
90            b'.'.value(ChangeKind::NotUpdated),
91        ));
92
93        let mut path_kind = alt((
94            b'f'.value(PathKind::File),
95            b'd'.value(PathKind::Directory),
96            b'L'.value(PathKind::Symlink),
97            b'D'.value(PathKind::Device),
98            b'S'.value(PathKind::SpecialFile),
99        ));
100
101        let mut ignored = (
102            // attribute changes
103            repeat::<_, _, (), _, _>(
104                9,
105                one_of((b'a'..=b'z', b'A'..=b'Z', b'.', b'+', b' ', b'?')),
106            ),
107            // separator space
108            b" ",
109        );
110
111        alt((
112            // empty string
113            eof.value(Self::Empty),
114            // rsync message
115            preceded(b'*', rest.map(OsStr::from_bytes).map(Self::Message)),
116            // item change
117            seq!(Self::PathChange {
118                change_kind: change_kind,
119                path_kind: path_kind,
120                _: ignored,
121                path: rest.map(|p: &[u8]| Path::new(OsStr::from_bytes(p))),
122            }),
123        ))
124        .parse_next(line)
125    }
126
127    /// Check if file content was changed in such a way that it should be extracted again.
128    ///
129    /// Returns `Ok(Some(path))`, where `path` points to the file if there were changes.
130    /// Otherwise `Ok(None)`.
131    ///
132    /// This does **not** consider the following to be "relevant" changes:
133    /// - Directory creation or other ["local changes" as defined by rsync](https://man.archlinux.org/man/rsync.1#o~61).
134    /// - Sending to remote. This should *not* be possible when using this tool.
135    /// - Attribute changes.
136    /// - File deletion.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if `self`:
141    /// - Holds a non-deletion message from rsync.
142    /// - Reports a change to a non-file object. See [`PathKind`].
143    pub(crate) fn file_content_updated(&self) -> Result<Option<&Path>> {
144        use Report::*;
145
146        match self {
147            // a file was changed
148            PathChange {
149                change_kind: ChangeKind::Received | ChangeKind::Hardlink,
150                path_kind: PathKind::File,
151                path,
152            } => Ok(Some(path)),
153            // a non-file path was changed
154            PathChange {
155                change_kind: ChangeKind::Received | ChangeKind::Hardlink,
156                path_kind,
157                path,
158            } => Err(anyhow!(
159                "Got unexpected path kind {path_kind:?} in rsync change list for '{path:?}'"
160            )),
161            // something was sent to the remote
162            PathChange {
163                change_kind: ChangeKind::Sent,
164                path,
165                ..
166            } => {
167                log::warn!(
168                    "Path '{path:?}' reported as changed on the remote host, this should not happen",
169                );
170                Ok(None)
171            }
172            // a message other than deletion was returned
173            Message(msg) if !msg.as_bytes().starts_with(b"deleting") => Err(anyhow!(
174                "rsync message found while looking for changes: '{}'",
175                msg.to_string_lossy()
176            )),
177            // all other cases are considered "unchanged"
178            _ => Ok(None),
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use std::path::PathBuf;
186
187    use proptest::proptest;
188    use rstest::rstest;
189    use testresult::TestResult;
190
191    use super::*;
192
193    proptest! {
194        #[test]
195        fn rsync_file_no_panic(input: Vec<u8>) {
196            // print to guarantee that the compiler doesn't optimise the call away
197            println!("{:?}", Report::parser(&input));
198        }
199
200        #[test]
201        fn rsync_parses(magic_str in "[<>ch.][fdLDS][.+ ?a-z]{9} ", path: PathBuf) {
202            let mut input = magic_str.into_bytes();
203            input.extend_from_slice(path.as_os_str().as_bytes());
204            // unwrap to panic if the call produces an error
205            Report::parser(&input).unwrap();
206        }
207    }
208
209    #[rstest]
210    #[case::changed(b">f+++++++++ file.name", b"file.name")]
211    #[case::hardlink(b"hf+++++++++ hard.link", b"hard.link")]
212    #[case::file_with_space(b">f+++++++++ file with space.ext", b"file with space.ext")]
213    #[case::file_with_trailing_newline(
214        b">f+++++++++ file_with_newline.ext\n",
215        b"file_with_newline.ext\n"
216    )]
217    #[case::blank_filename(b">f+++++++++ ", b"")]
218    fn rsync_changed(#[case] input: &[u8], #[case] changed: &[u8]) -> TestResult {
219        assert_eq!(
220            Report::parser(input)?
221                .file_content_updated()?
222                .ok_or("rsync reported no change")?
223                .as_os_str()
224                .as_bytes(),
225            changed
226        );
227        Ok(())
228    }
229
230    #[rstest]
231    #[case::received(
232        b">f+++++++++ received_file.txt",
233        ChangeKind::Received,
234        PathKind::File,
235        b"received_file.txt"
236    )]
237    #[case::sent(
238        b"<f+++++++++ sent_file.txt",
239        ChangeKind::Sent,
240        PathKind::File,
241        b"sent_file.txt"
242    )]
243    #[case::local(
244        b"cL+++++++++ changed.md",
245        ChangeKind::LocalChange,
246        PathKind::Symlink,
247        b"changed.md"
248    )]
249    #[case::hardlink(
250        b"hf+++++++++ hard.link",
251        ChangeKind::Hardlink,
252        PathKind::File,
253        b"hard.link"
254    )]
255    #[case::not_updated(
256        b".D+++++++++ /dev/sda0",
257        ChangeKind::NotUpdated,
258        PathKind::Device,
259        b"/dev/sda0"
260    )]
261    #[case::directory(
262        b"cd+++++++++ directory_one",
263        ChangeKind::LocalChange,
264        PathKind::Directory,
265        b"directory_one"
266    )]
267    fn rsync_change_kind(
268        #[case] input: &[u8],
269        #[case] expected_change_kind: ChangeKind,
270        #[case] expected_path_kind: PathKind,
271        #[case] changed_name: &[u8],
272    ) -> TestResult {
273        let Report::PathChange {
274            change_kind,
275            path_kind,
276            path,
277        } = Report::parser(input)?
278        else {
279            panic!("rsync didn't report a change");
280        };
281        assert_eq!(change_kind, expected_change_kind);
282        assert_eq!(path_kind, expected_path_kind);
283        assert_eq!(path.as_os_str().as_bytes(), changed_name);
284        Ok(())
285    }
286
287    #[rstest]
288    #[case::attr_change(b".f+++++++++ file.name")]
289    #[case::changed_remotely(b"<f+++++++++ file.name")]
290    #[case::empty(b"")]
291    #[case::deleted(b"*deleting file.name")]
292    fn rsync_unchanged(#[case] input: &[u8]) -> TestResult {
293        assert_eq!(Report::parser(input)?.file_content_updated()?, None);
294        Ok(())
295    }
296
297    #[rstest]
298    #[case::no_space(b">f+++++++++file.name")]
299    #[case::invalid_first_char(b"Bf+++++++++ file.name")]
300    #[case::no_file(b">f+++++++++")]
301    fn rsync_report_parse_error(#[case] input: &[u8]) {
302        assert!(Report::parser(input).is_err());
303    }
304}