dev_scripts/sync/mirror/
rsync_changes.rs

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