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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(crate) enum ChangeKind {
16 Sent,
18 Received,
20 LocalChange,
22 Hardlink,
24 NotUpdated,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub(crate) enum PathKind {
37 File,
39 Directory,
41 Symlink,
43 Device,
45 SpecialFile,
49}
50
51#[derive(Clone, Debug)]
55pub(crate) enum Report<'a> {
56 Message(&'a OsStr),
58 PathChange {
60 change_kind: ChangeKind,
62 path_kind: PathKind,
64 path: &'a Path,
66 },
67 Empty,
69}
70
71impl<'a> Report<'a> {
72 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 repeat::<_, _, (), _, _>(
104 9,
105 one_of((b'a'..=b'z', b'A'..=b'Z', b'.', b'+', b' ', b'?')),
106 ),
107 b" ",
109 );
110
111 alt((
112 eof.value(Self::Empty),
114 preceded(b'*', rest.map(OsStr::from_bytes).map(Self::Message)),
116 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 pub(crate) fn file_content_updated(&self) -> Result<Option<&Path>> {
144 use Report::*;
145
146 match self {
147 PathChange {
149 change_kind: ChangeKind::Received | ChangeKind::Hardlink,
150 path_kind: PathKind::File,
151 path,
152 } => Ok(Some(path)),
153 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 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 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 _ => 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 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 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}