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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub(crate) enum ChangeKind {
19 Sent,
21 Received,
23 LocalChange,
25 Hardlink,
27 NotUpdated,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub(crate) enum PathKind {
40 File,
42 Directory,
44 Symlink,
46 Device,
48 SpecialFile,
52}
53
54#[derive(Clone, Debug)]
58pub(crate) enum Report<'a> {
59 Message(&'a OsStr),
61 PathChange {
63 change_kind: ChangeKind,
65 path_kind: PathKind,
67 path: &'a Path,
69 },
70 Empty,
72}
73
74impl<'a> Report<'a> {
75 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 repeat::<_, _, (), _, _>(
107 9,
108 one_of((b'a'..=b'z', b'A'..=b'Z', b'.', b'+', b' ', b'?')),
109 ),
110 b" ",
112 );
113
114 alt((
115 eof.value(Self::Empty),
117 preceded(b'*', rest.map(OsStr::from_bytes).map(Self::Message)),
119 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 pub(crate) fn file_content_updated(&self) -> Result<Option<&Path>, Error> {
147 use Report::*;
148
149 match self {
150 PathChange {
152 change_kind: ChangeKind::Received | ChangeKind::Hardlink,
153 path_kind: PathKind::File,
154 path,
155 } => Ok(Some(path)),
156 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 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 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 _ => 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 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 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}