1use std::collections::HashMap;
36use std::fmt;
37use std::path::{Path, PathBuf};
38use std::process::{Command, Stdio};
39use std::sync::{Mutex, OnceLock};
40use std::time::{Duration, Instant};
41
42use serde::{Deserialize, Serialize};
43
44pub const RSYNC_BIN_ENV: &str = "RCH_RSYNC_BIN";
48
49pub const PREFERRED_RSYNC_LOCATIONS: &[&str] = &[
53 "/opt/homebrew/bin/rsync",
54 "/usr/local/bin/rsync",
55 "/opt/local/bin/rsync",
56 "/usr/local/opt/rsync/bin/rsync",
57 "/opt/homebrew/opt/rsync/bin/rsync",
58];
59
60pub const MIN_SUPPORTED_RSYNC: (u32, u32, u32) = (2, 6, 9);
64
65const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
68
69const MAX_ZLIB_COMPRESS_LEVEL: u32 = 9;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum RsyncFlavor {
77 Rsync { major: u32, minor: u32, patch: u32 },
79 OpenRsync {
81 protocol: Option<u32>,
83 },
84 Unknown,
86}
87
88impl RsyncFlavor {
89 #[must_use]
103 pub fn parse_version_output(text: &str) -> Self {
104 let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
105 let Some(first) = lines.next() else {
106 return Self::Unknown;
107 };
108 let lowered = first.to_ascii_lowercase();
109 if lowered.starts_with("openrsync") {
110 let protocol = lowered
111 .split("protocol version")
112 .nth(1)
113 .and_then(|rest| rest.split_whitespace().next())
114 .and_then(|token| token.parse().ok());
115 return Self::OpenRsync { protocol };
116 }
117 if lowered.starts_with("rsync")
118 && let Some(rest) = lowered.split("version").nth(1)
119 && let Some(token) = rest.split_whitespace().next()
120 && let Some((major, minor, patch)) = parse_semver_prefix(token)
121 {
122 return Self::Rsync {
123 major,
124 minor,
125 patch,
126 };
127 }
128 Self::Unknown
129 }
130
131 #[must_use]
135 pub const fn escapes_remote_args(self) -> bool {
136 match self {
137 Self::Rsync {
138 major,
139 minor,
140 patch,
141 } => major > 3 || (major == 3 && (minor > 2 || (minor == 2 && patch >= 4))),
142 Self::OpenRsync { .. } => false,
143 Self::Unknown => true,
144 }
145 }
146
147 #[must_use]
162 pub fn remote_path_arg(self, path: &str) -> String {
163 if !self.escapes_remote_args() {
164 return shell_escape::escape(std::borrow::Cow::Borrowed(path)).into_owned();
165 }
166 if self.needs_literal_remote_path(path) {
167 return if path.ends_with('/') { "./" } else { "." }.to_string();
168 }
169 path.to_string()
170 }
171
172 fn needs_literal_remote_path(self, path: &str) -> bool {
173 self.escapes_remote_args() && path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
174 }
175
176 #[must_use]
184 pub fn remote_path_command(self, command: String, path: &str) -> String {
185 if !self.needs_literal_remote_path(path) {
186 return command;
187 }
188 let literal = shell_escape::escape(std::borrow::Cow::Borrowed(path));
189 let script = format!(
190 "rch_argc=$#\n\
191 [ \"$rch_argc\" -gt 0 ] || exit 2\n\
192 while [ \"$rch_argc\" -gt 0 ]; do\n\
193 rch_arg=$1; shift; rch_argc=$((rch_argc - 1))\n\
194 if [ \"$rch_argc\" -eq 0 ]; then\n\
195 case \"$rch_arg\" in .|./) ;; *) exit 2 ;; esac\n\
196 set -- \"$@\" {literal}\n\
197 else\n\
198 set -- \"$@\" \"$rch_arg\"\n\
199 fi\n\
200 done\n\
201 {command} \"$@\""
202 );
203 format!(
204 "sh -c {} rch-rsync-literal-path",
205 shell_escape::escape(std::borrow::Cow::Owned(script))
206 )
207 }
208
209 #[must_use]
212 pub const fn supports_info_flags(self) -> bool {
213 self.capabilities().info_flags
214 }
215
216 #[must_use]
221 pub const fn is_supported(self) -> bool {
222 match self {
223 Self::Rsync {
224 major,
225 minor,
226 patch,
227 } => {
228 let (min_major, min_minor, min_patch) = MIN_SUPPORTED_RSYNC;
229 if major != min_major {
230 major > min_major
231 } else if minor != min_minor {
232 minor > min_minor
233 } else {
234 patch >= min_patch
235 }
236 }
237 Self::OpenRsync { .. } | Self::Unknown => true,
238 }
239 }
240
241 #[must_use]
247 pub const fn capabilities(self) -> RsyncCapabilities {
248 match self {
249 Self::Rsync { major, minor, .. } => RsyncCapabilities {
250 info_flags: version_at_least(major, minor, 3, 1),
253 stats_regular_file_breakdown: version_at_least(major, minor, 3, 1),
254 compress_choice: version_at_least(major, minor, 3, 2),
256 append_verify: version_at_least(major, minor, 3, 0),
258 no_motd: version_at_least(major, minor, 3, 0),
259 },
260 Self::OpenRsync { .. } => RsyncCapabilities {
261 info_flags: false,
262 stats_regular_file_breakdown: false,
263 compress_choice: false,
264 append_verify: false,
265 no_motd: true,
266 },
267 Self::Unknown => RsyncCapabilities::MODERN,
268 }
269 }
270}
271
272pub fn configure_rsync_remote_args(command: &mut Command) {
277 command
278 .env("RSYNC_OLD_ARGS", "0")
279 .env("RSYNC_PROTECT_ARGS", "0");
280}
281
282impl fmt::Display for RsyncFlavor {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Self::Rsync {
286 major,
287 minor,
288 patch,
289 } => write!(f, "rsync {major}.{minor}.{patch}"),
290 Self::OpenRsync {
291 protocol: Some(protocol),
292 } => write!(f, "openrsync (protocol {protocol})"),
293 Self::OpenRsync { protocol: None } => f.write_str("openrsync"),
294 Self::Unknown => f.write_str("unrecognized rsync"),
295 }
296 }
297}
298
299const fn version_at_least(major: u32, minor: u32, want_major: u32, want_minor: u32) -> bool {
300 major > want_major || (major == want_major && minor >= want_minor)
301}
302
303fn parse_semver_prefix(token: &str) -> Option<(u32, u32, u32)> {
304 let numeric: String = token
307 .chars()
308 .take_while(|ch| ch.is_ascii_digit() || *ch == '.')
309 .collect();
310 let mut parts = numeric.split('.').filter(|part| !part.is_empty());
311 let major = parts.next()?.parse().ok()?;
312 let minor = parts.next().map_or(Some(0), |part| part.parse().ok())?;
313 let patch = parts.next().map_or(Some(0), |part| part.parse().ok())?;
314 Some((major, minor, patch))
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
320pub struct RsyncCapabilities {
321 pub info_flags: bool,
323 pub stats_regular_file_breakdown: bool,
327 pub compress_choice: bool,
330 pub append_verify: bool,
333 pub no_motd: bool,
335}
336
337impl RsyncCapabilities {
338 pub const MODERN: Self = Self {
340 info_flags: true,
341 stats_regular_file_breakdown: true,
342 compress_choice: true,
343 append_verify: true,
344 no_motd: true,
345 };
346
347 pub const LEGACY: Self = Self {
349 info_flags: false,
350 stats_regular_file_breakdown: false,
351 compress_choice: false,
352 append_verify: false,
353 no_motd: false,
354 };
355
356 #[must_use]
359 pub const fn is_compatibility_mode(&self) -> bool {
360 !self.info_flags
361 }
362
363 #[must_use]
368 pub const fn progress_args(&self) -> &'static [&'static str] {
369 if self.info_flags {
370 &["--info=progress2"]
371 } else {
372 &["--progress"]
373 }
374 }
375
376 #[must_use]
380 pub const fn stats_args(&self) -> &'static [&'static str] {
381 if self.info_flags {
382 &["--info=stats2"]
383 } else {
384 &["--stats"]
385 }
386 }
387
388 #[must_use]
394 pub const fn name_listing_args(&self) -> &'static [&'static str] {
395 if self.info_flags {
396 &["--info=name2"]
397 } else {
398 &["-vv"]
399 }
400 }
401
402 #[must_use]
408 pub fn compression_args(&self, level: u32) -> Vec<String> {
409 if level == 0 {
410 return Vec::new();
411 }
412 if self.compress_choice {
413 vec![
414 "--compress-choice=zstd".to_string(),
415 format!("--compress-level={level}"),
416 ]
417 } else {
418 vec![format!(
419 "--compress-level={}",
420 level.min(MAX_ZLIB_COMPRESS_LEVEL)
421 )]
422 }
423 }
424
425 #[must_use]
429 pub const fn resume_args(&self) -> &'static [&'static str] {
430 if self.append_verify {
431 &["--partial", "--append-verify"]
432 } else {
433 &["--partial"]
434 }
435 }
436
437 #[must_use]
440 pub const fn no_motd_args(&self) -> &'static [&'static str] {
441 if self.no_motd { &["--no-motd"] } else { &[] }
442 }
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum RsyncSource {
449 Env,
451 Config,
453 Path,
455 PreferredLocation,
458}
459
460impl fmt::Display for RsyncSource {
461 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462 f.write_str(match self {
463 Self::Env => RSYNC_BIN_ENV,
464 Self::Config => "[transfer] rsync_bin",
465 Self::Path => "PATH",
466 Self::PreferredLocation => "well-known install location",
467 })
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct ResolvedRsync {
474 pub path: PathBuf,
476 pub flavor: RsyncFlavor,
478 pub version_line: String,
480 pub source: RsyncSource,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub shadowed: Option<ShadowedRsync>,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct ShadowedRsync {
492 pub path: PathBuf,
493 pub flavor: RsyncFlavor,
494}
495
496impl ResolvedRsync {
497 #[must_use]
499 pub const fn capabilities(&self) -> RsyncCapabilities {
500 self.flavor.capabilities()
501 }
502
503 #[must_use]
506 pub fn describe(&self) -> String {
507 let mut text = format!(
508 "{} at {} ({})",
509 self.flavor,
510 self.path.display(),
511 self.source
512 );
513 if let Some(shadowed) = &self.shadowed {
514 text.push_str(&format!(
515 "; PATH rsync is {} at {}",
516 shadowed.flavor,
517 shadowed.path.display()
518 ));
519 }
520 text
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
526pub enum RsyncResolveError {
527 #[error("rsync not found on PATH or in {}", PREFERRED_RSYNC_LOCATIONS.join(", "))]
529 NotFound,
530 #[error("{origin} points at {path}, which is not an executable file")]
533 OverrideMissing { origin: RsyncSource, path: PathBuf },
534 #[error("{path} --version failed: {reason}")]
536 ProbeFailed { path: PathBuf, reason: String },
537}
538
539pub fn probe_rsync_version(path: &Path) -> Result<String, RsyncResolveError> {
542 let fail = |reason: String| RsyncResolveError::ProbeFailed {
543 path: path.to_path_buf(),
544 reason,
545 };
546 let mut child = Command::new(path)
547 .arg("--version")
548 .env("LC_ALL", "C")
549 .stdin(Stdio::null())
550 .stdout(Stdio::piped())
551 .stderr(Stdio::piped())
552 .spawn()
553 .map_err(|error| fail(error.to_string()))?;
554
555 let started = Instant::now();
556 loop {
557 match child.try_wait() {
558 Ok(Some(_)) => break,
559 Ok(None) if started.elapsed() >= VERSION_PROBE_TIMEOUT => {
560 let _ = child.kill();
561 let _ = child.wait();
562 return Err(fail(format!(
563 "timed out after {}s",
564 VERSION_PROBE_TIMEOUT.as_secs()
565 )));
566 }
567 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
568 Err(error) => return Err(fail(error.to_string())),
569 }
570 }
571 let output = child
572 .wait_with_output()
573 .map_err(|error| fail(error.to_string()))?;
574 let stdout = String::from_utf8_lossy(&output.stdout);
575 let stderr = String::from_utf8_lossy(&output.stderr);
576 let text = if stdout.trim().is_empty() {
577 stderr.as_ref()
578 } else {
579 stdout.as_ref()
580 };
581 text.lines()
582 .map(str::trim)
583 .find(|line| !line.is_empty())
584 .map(str::to_string)
585 .ok_or_else(|| fail("no version output".to_string()))
586}
587
588fn is_executable_file(path: &Path) -> bool {
589 let Ok(metadata) = std::fs::metadata(path) else {
590 return false;
591 };
592 if !metadata.is_file() {
593 return false;
594 }
595 #[cfg(unix)]
596 {
597 use std::os::unix::fs::PermissionsExt;
598 metadata.permissions().mode() & 0o111 != 0
599 }
600 #[cfg(not(unix))]
601 {
602 true
603 }
604}
605
606fn find_in_path(name: &str) -> Option<PathBuf> {
608 let path_var = std::env::var_os("PATH")?;
609 let names: Vec<String> = if cfg!(windows) {
610 vec![format!("{name}.exe"), name.to_string()]
611 } else {
612 vec![name.to_string()]
613 };
614 std::env::split_paths(&path_var)
615 .filter(|dir| !dir.as_os_str().is_empty())
616 .flat_map(|dir| names.iter().map(move |n| dir.join(n)))
617 .find(|candidate| is_executable_file(candidate))
618}
619
620fn override_path(value: &str) -> Option<PathBuf> {
623 let expanded = shellexpand::tilde(value.trim()).into_owned();
624 if expanded.is_empty() {
625 return None;
626 }
627 let path = PathBuf::from(&expanded);
628 if path.components().count() > 1 || path.is_absolute() {
629 Some(path)
630 } else {
631 find_in_path(&expanded).or(Some(path))
632 }
633}
634
635pub fn resolve_rsync(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
643 let env_override = std::env::var(RSYNC_BIN_ENV)
644 .ok()
645 .filter(|value| !value.trim().is_empty());
646 let explicit = env_override
647 .as_deref()
648 .map(|value| (value, RsyncSource::Env))
649 .or_else(|| {
650 configured
651 .filter(|value| !value.trim().is_empty())
652 .map(|value| (value, RsyncSource::Config))
653 });
654
655 if let Some((value, source)) = explicit {
656 let path = override_path(value).ok_or_else(|| RsyncResolveError::OverrideMissing {
657 origin: source,
658 path: PathBuf::from(value),
659 })?;
660 if !is_executable_file(&path) {
661 return Err(RsyncResolveError::OverrideMissing {
662 origin: source,
663 path,
664 });
665 }
666 let version_line = probe_rsync_version(&path)?;
667 return Ok(ResolvedRsync {
668 flavor: RsyncFlavor::parse_version_output(&version_line),
669 path,
670 version_line,
671 source,
672 shadowed: None,
673 });
674 }
675
676 let path_candidate = find_in_path("rsync").map(|path| {
677 let probed = probe_rsync_version(&path);
678 (path, probed)
679 });
680
681 if let Some((path, Ok(version_line))) = &path_candidate {
683 let flavor = RsyncFlavor::parse_version_output(version_line);
684 if flavor.supports_info_flags() {
685 return Ok(ResolvedRsync {
686 path: path.clone(),
687 flavor,
688 version_line: version_line.clone(),
689 source: RsyncSource::Path,
690 shadowed: None,
691 });
692 }
693 }
694
695 for location in PREFERRED_RSYNC_LOCATIONS {
698 let path = PathBuf::from(location);
699 if path_candidate
700 .as_ref()
701 .is_some_and(|(path_path, _)| same_file(path_path, &path))
702 || !is_executable_file(&path)
703 {
704 continue;
705 }
706 let Ok(version_line) = probe_rsync_version(&path) else {
707 continue;
708 };
709 let flavor = RsyncFlavor::parse_version_output(&version_line);
710 if !flavor.supports_info_flags() {
711 continue;
712 }
713 let shadowed = path_candidate
714 .as_ref()
715 .map(|(path_path, probed)| ShadowedRsync {
716 path: path_path.clone(),
717 flavor: probed
718 .as_deref()
719 .map_or(RsyncFlavor::Unknown, RsyncFlavor::parse_version_output),
720 });
721 return Ok(ResolvedRsync {
722 path,
723 flavor,
724 version_line,
725 source: RsyncSource::PreferredLocation,
726 shadowed,
727 });
728 }
729
730 match path_candidate {
733 Some((path, Ok(version_line))) => Ok(ResolvedRsync {
734 flavor: RsyncFlavor::parse_version_output(&version_line),
735 path,
736 version_line,
737 source: RsyncSource::Path,
738 shadowed: None,
739 }),
740 Some((_, Err(error))) => Err(error),
741 None => Err(RsyncResolveError::NotFound),
742 }
743}
744
745fn same_file(a: &Path, b: &Path) -> bool {
746 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
747 (Ok(a), Ok(b)) => a == b,
748 _ => a == b,
749 }
750}
751
752type ResolveCache = Mutex<HashMap<Option<String>, Result<ResolvedRsync, RsyncResolveError>>>;
753
754fn resolve_cache() -> &'static ResolveCache {
755 static CACHE: OnceLock<ResolveCache> = OnceLock::new();
756 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
757}
758
759pub fn resolve_rsync_cached(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
765 let key = configured.map(str::to_string);
766 if let Ok(cache) = resolve_cache().lock()
767 && let Some(cached) = cache.get(&key)
768 {
769 return cached.clone();
770 }
771 let resolved = resolve_rsync(configured);
772 if let Ok(mut cache) = resolve_cache().lock() {
773 cache.insert(key, resolved.clone());
774 }
775 resolved
776}
777
778pub fn clear_resolve_cache() {
781 if let Ok(mut cache) = resolve_cache().lock() {
782 cache.clear();
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn remote_path_arg_version_boundary_and_plain_path_control() {
792 let versions = [
793 ((2, 6, 9), false),
794 ((3, 0, 9), false),
795 ((3, 1, 3), false),
796 ((3, 2, 3), false),
797 ((3, 2, 4), true),
798 ((3, 2, 7), true),
799 ((3, 4, 1), true),
800 ((4, 0, 0), true),
801 ];
802 for ((major, minor, patch), modern) in versions {
803 let flavor = RsyncFlavor::Rsync {
804 major,
805 minor,
806 patch,
807 };
808 assert_eq!(flavor.escapes_remote_args(), modern, "{flavor}");
809 }
810 assert!(!RsyncFlavor::OpenRsync { protocol: Some(29) }.escapes_remote_args());
811 assert!(RsyncFlavor::Unknown.escapes_remote_args());
812 for (flavor, modern) in [
813 (RsyncFlavor::OpenRsync { protocol: Some(29) }, false),
814 (RsyncFlavor::Unknown, true),
815 ] {
816 assert_eq!(flavor.escapes_remote_args(), modern, "{flavor}");
817 assert_eq!(
818 flavor.remote_path_arg("/data/projects/plain/"),
819 "/data/projects/plain/"
820 );
821 for path in ["/data/projects/p q/", "/data/projects/x:y/"] {
822 let expected = if modern {
823 path.to_string()
824 } else {
825 format!("'{path}'")
826 };
827 assert_eq!(flavor.remote_path_arg(path), expected, "{flavor}");
828 }
829 }
830 }
831
832 #[test]
833 fn remote_path_arg_preserves_literal_metacharacters() {
834 let flavor = RsyncFlavor::Rsync {
835 major: 3,
836 minor: 2,
837 patch: 4,
838 };
839 for (path, expected) in [
840 ("/data/p'q:x/", "/data/p'q:x/"),
841 (
842 "/data/$(touch marker);$HOME/",
843 "/data/$(touch marker);$HOME/",
844 ),
845 (r"/data/a\b/", r"/data/a\b/"),
846 ("/data/a[1]/", "./"),
847 ("/data/a*?/", "./"),
848 (r"/data/a\[1]/", "./"),
849 ("/data/a*/archive.tar", "."),
850 ("/data/p q/out:x/", "/data/p q/out:x/"),
851 ] {
852 assert_eq!(flavor.remote_path_arg(path), expected);
853 }
854 }
855
856 #[test]
857 fn remote_argument_mode_overrides_are_child_local() {
858 use std::ffi::OsStr;
859
860 let mut command = Command::new("rsync");
861 command
862 .env("RSYNC_OLD_ARGS", "2")
863 .env("RSYNC_PROTECT_ARGS", "1");
864 configure_rsync_remote_args(&mut command);
865 for key in ["RSYNC_OLD_ARGS", "RSYNC_PROTECT_ARGS"] {
866 assert_eq!(
867 command.get_envs().find(|(name, _)| *name == key),
868 Some((OsStr::new(key), Some(OsStr::new("0"))))
869 );
870 }
871 }
872
873 #[cfg(unix)]
876 #[test]
877 fn remote_path_arg_real_rsync_round_trip() {
878 use std::os::unix::fs::{PermissionsExt, symlink};
879
880 let resolved = match resolve_rsync_cached(None) {
881 Ok(resolved) => resolved,
882 Err(RsyncResolveError::NotFound) => {
883 eprintln!("skipping real rsync regression: rsync is not installed");
884 return;
885 }
886 Err(error) => panic!("cannot resolve test rsync: {error}"),
887 };
888 let temp = tempfile::tempdir().expect("fixture directory");
889 let rsh = temp.path().join("rsh");
890 std::fs::write(&rsh, "#!/bin/sh\nshift\nexec /bin/sh -c \"$*\"\n")
893 .expect("write remote shell");
894 std::fs::set_permissions(&rsh, std::fs::Permissions::from_mode(0o700))
895 .expect("executable remote shell");
896 let run = |flags: &[&str], source: &str, destination: &str, remote: &str| {
897 let mut command = Command::new(&resolved.path);
898 command
899 .env("RSYNC_OLD_ARGS", "1")
900 .env("RSYNC_PROTECT_ARGS", "1");
901 configure_rsync_remote_args(&mut command);
902 let output = command
903 .env("LC_ALL", "C")
904 .args(flags)
905 .arg("-e")
906 .arg(shell_escape::escape(rsh.to_string_lossy()).as_ref())
907 .arg("--rsync-path")
908 .arg(resolved.flavor.remote_path_command(
909 shell_escape::escape(resolved.path.to_string_lossy()).into_owned(),
910 remote,
911 ))
912 .arg(source)
913 .arg(destination)
914 .output()
915 .expect("run rsync");
916 assert!(
917 output.status.success(),
918 "rsync {flags:?} {source:?} {destination:?}: {}",
919 String::from_utf8_lossy(&output.stderr)
920 );
921 output
922 };
923 let worker = temp.path().join("worker");
924 std::fs::create_dir_all(worker.join("a1")).expect("shadow directory");
925 std::fs::write(worker.join("a1/payload"), "wrong sibling").expect("shadow file");
926 let secret = temp.path().join("outside-secret");
927 std::fs::write(&secret, "must not be retrieved").expect("outside file");
928 for name in ["plain", "p q", "x:y", "a[1]", "a*", r"back\*"] {
929 let source = temp.path().join("source").join(name);
930 let remote = worker.join(name);
931 let local = temp.path().join("retrieved").join(name);
932 std::fs::create_dir_all(&source).expect("source directory");
933 std::fs::create_dir_all(&local).expect("retrieval directory");
934 std::fs::write(source.join("payload"), name).expect("source payload");
935 let remote_raw = format!("{}/", remote.display());
936 let remote_operand =
937 format!("loopback:{}", resolved.flavor.remote_path_arg(&remote_raw));
938 let source_operand = format!("{}/", source.display());
939 run(&["-a"], &source_operand, &remote_operand, &remote_raw);
940 assert_eq!(
941 std::fs::read(remote.join("payload")).unwrap(),
942 name.as_bytes()
943 );
944 let barrier = run(
945 &["-a", "--checksum", "--dry-run", "--itemize-changes"],
946 &source_operand,
947 &remote_operand,
948 &remote_raw,
949 );
950 assert!(barrier.stdout.is_empty(), "no-delta barrier: {name}");
951 symlink(&secret, remote.join("escape-link")).expect("unsafe symlink fixture");
952 run(
953 &["-a", "--safe-links"],
954 &remote_operand,
955 &format!("{}/", local.display()),
956 &remote_raw,
957 );
958 assert_eq!(
959 std::fs::read(local.join("payload")).unwrap(),
960 name.as_bytes()
961 );
962 assert!(std::fs::symlink_metadata(local.join("escape-link")).is_err());
963 let result = remote.join("out p:q");
964 std::fs::create_dir_all(&result).expect("result directory");
965 std::fs::write(result.join("result"), name).expect("result payload");
966 run(
967 &["-a", "--safe-links"],
968 &format!(
969 "loopback:{}",
970 resolved
971 .flavor
972 .remote_path_arg(&format!("{}/", result.display()))
973 ),
974 &format!("{}/out p:q/", local.display()),
975 &format!("{}/", result.display()),
976 );
977 assert_eq!(
978 std::fs::read(local.join("out p:q/result")).unwrap(),
979 name.as_bytes()
980 );
981 let archive_raw = format!("{}/archive.tar", remote.display());
982 run(
983 &["-a"],
984 source.join("payload").to_str().unwrap(),
985 &format!("loopback:{}", resolved.flavor.remote_path_arg(&archive_raw)),
986 &archive_raw,
987 );
988 assert_eq!(
989 std::fs::read(remote.join("archive.tar")).unwrap(),
990 name.as_bytes()
991 );
992 }
993 }
994
995 const RSYNC_3_4_1: &str = "rsync version 3.4.1 protocol version 32\n\
997Copyright (C) 1996-2025 by Andrew Tridgell, Wayne Davison, and others.\n\
998Web site: https://rsync.samba.org/\n\
999Capabilities:\n 64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,\n";
1000
1001 const RSYNC_3_2_7: &str = "rsync version 3.2.7 protocol version 31\n\
1003Copyright (C) 1996-2022 by Andrew Tridgell, Wayne Davison, and others.\n";
1004
1005 const OPENRSYNC_MACOS: &str =
1007 "openrsync: protocol version 29\nrsync version 2.6.9 compatible\n";
1008
1009 const RSYNC_2_6_9_APPLE: &str = "rsync version 2.6.9 protocol version 29\n\
1011Copyright (C) 1996-2006 by Andrew Tridgell, Wayne Davison, and others.\n";
1012
1013 #[test]
1014 fn parses_upstream_3x_versions() {
1015 assert_eq!(
1016 RsyncFlavor::parse_version_output(RSYNC_3_4_1),
1017 RsyncFlavor::Rsync {
1018 major: 3,
1019 minor: 4,
1020 patch: 1
1021 }
1022 );
1023 assert_eq!(
1024 RsyncFlavor::parse_version_output(RSYNC_3_2_7),
1025 RsyncFlavor::Rsync {
1026 major: 3,
1027 minor: 2,
1028 patch: 7
1029 }
1030 );
1031 assert_eq!(
1032 RsyncFlavor::parse_version_output("rsync version 3.2.7pre1 protocol version 31"),
1033 RsyncFlavor::Rsync {
1034 major: 3,
1035 minor: 2,
1036 patch: 7
1037 }
1038 );
1039 assert_eq!(
1040 RsyncFlavor::parse_version_output("rsync version 3.5.0dev protocol version 32"),
1041 RsyncFlavor::Rsync {
1042 major: 3,
1043 minor: 5,
1044 patch: 0
1045 }
1046 );
1047 }
1048
1049 #[test]
1050 fn parses_openrsync_and_does_not_mistake_its_compat_line_for_2_6_9() {
1051 assert_eq!(
1052 RsyncFlavor::parse_version_output(OPENRSYNC_MACOS),
1053 RsyncFlavor::OpenRsync { protocol: Some(29) }
1054 );
1055 assert_eq!(
1056 RsyncFlavor::parse_version_output("openrsync: protocol version"),
1057 RsyncFlavor::OpenRsync { protocol: None }
1058 );
1059 }
1060
1061 #[test]
1062 fn parses_apple_legacy_2_6_9() {
1063 assert_eq!(
1064 RsyncFlavor::parse_version_output(RSYNC_2_6_9_APPLE),
1065 RsyncFlavor::Rsync {
1066 major: 2,
1067 minor: 6,
1068 patch: 9
1069 }
1070 );
1071 }
1072
1073 #[test]
1074 fn unrecognized_banner_is_unknown_and_assumed_modern() {
1075 for text in [
1076 "",
1077 "\n\n",
1078 "rsync: command not found",
1079 "usage: rsync [-0468BC...]",
1080 ] {
1081 assert_eq!(
1082 RsyncFlavor::parse_version_output(text),
1083 RsyncFlavor::Unknown,
1084 "{text:?}"
1085 );
1086 }
1087 assert_eq!(
1088 RsyncFlavor::Unknown.capabilities(),
1089 RsyncCapabilities::MODERN
1090 );
1091 assert!(RsyncFlavor::Unknown.is_supported());
1092 }
1093
1094 #[test]
1095 fn capabilities_follow_version_thresholds() {
1096 let v = |major, minor| RsyncFlavor::Rsync {
1097 major,
1098 minor,
1099 patch: 0,
1100 };
1101 assert_eq!(v(3, 4).capabilities(), RsyncCapabilities::MODERN);
1102 assert_eq!(v(3, 2).capabilities(), RsyncCapabilities::MODERN);
1103 let three_one = v(3, 1).capabilities();
1104 assert!(three_one.info_flags);
1105 assert!(three_one.stats_regular_file_breakdown);
1106 assert!(!three_one.compress_choice);
1107 assert!(three_one.append_verify);
1108 let three_zero = v(3, 0).capabilities();
1109 assert!(!three_zero.info_flags);
1110 assert!(!three_zero.compress_choice);
1111 assert!(three_zero.append_verify);
1112 assert!(three_zero.no_motd);
1113 assert_eq!(v(2, 6).capabilities(), RsyncCapabilities::LEGACY);
1114 let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
1115 assert!(!openrsync.info_flags);
1116 assert!(!openrsync.compress_choice);
1117 assert!(!openrsync.append_verify);
1118 assert!(openrsync.no_motd);
1119 assert!(openrsync.is_compatibility_mode());
1120 assert!(!RsyncCapabilities::MODERN.is_compatibility_mode());
1121 }
1122
1123 #[test]
1124 fn support_floor_is_2_6_9() {
1125 let v = |major, minor, patch| RsyncFlavor::Rsync {
1126 major,
1127 minor,
1128 patch,
1129 };
1130 assert!(v(2, 6, 9).is_supported());
1131 assert!(v(3, 0, 0).is_supported());
1132 assert!(!v(2, 6, 8).is_supported());
1133 assert!(!v(2, 5, 7).is_supported());
1134 assert!(RsyncFlavor::OpenRsync { protocol: None }.is_supported());
1135 }
1136
1137 #[test]
1138 fn argv_fragments_per_flavour() {
1139 let modern = RsyncCapabilities::MODERN;
1140 assert_eq!(modern.progress_args(), ["--info=progress2"]);
1141 assert_eq!(modern.stats_args(), ["--info=stats2"]);
1142 assert_eq!(modern.name_listing_args(), ["--info=name2"]);
1143 assert_eq!(modern.resume_args(), ["--partial", "--append-verify"]);
1144 assert_eq!(modern.no_motd_args(), ["--no-motd"]);
1145 assert_eq!(
1146 modern.compression_args(7),
1147 ["--compress-choice=zstd", "--compress-level=7"]
1148 );
1149 assert_eq!(
1150 modern.compression_args(19),
1151 ["--compress-choice=zstd", "--compress-level=19"]
1152 );
1153 assert!(modern.compression_args(0).is_empty());
1154
1155 let legacy = RsyncCapabilities::LEGACY;
1156 assert_eq!(legacy.progress_args(), ["--progress"]);
1157 assert_eq!(legacy.stats_args(), ["--stats"]);
1158 assert_eq!(legacy.name_listing_args(), ["-vv"]);
1159 assert_eq!(legacy.resume_args(), ["--partial"]);
1160 assert!(legacy.no_motd_args().is_empty());
1161 assert_eq!(legacy.compression_args(7), ["--compress-level=7"]);
1162 assert_eq!(legacy.compression_args(19), ["--compress-level=9"]);
1164 assert!(legacy.compression_args(0).is_empty());
1165
1166 let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
1167 assert_eq!(openrsync.no_motd_args(), ["--no-motd"]);
1168 assert_eq!(openrsync.progress_args(), ["--progress"]);
1169 }
1170
1171 #[test]
1172 fn display_and_describe() {
1173 assert_eq!(
1174 RsyncFlavor::Rsync {
1175 major: 3,
1176 minor: 4,
1177 patch: 1
1178 }
1179 .to_string(),
1180 "rsync 3.4.1"
1181 );
1182 assert_eq!(
1183 RsyncFlavor::OpenRsync { protocol: Some(29) }.to_string(),
1184 "openrsync (protocol 29)"
1185 );
1186 let resolved = ResolvedRsync {
1187 path: PathBuf::from("/opt/homebrew/bin/rsync"),
1188 flavor: RsyncFlavor::Rsync {
1189 major: 3,
1190 minor: 4,
1191 patch: 1,
1192 },
1193 version_line: "rsync version 3.4.1 protocol version 32".to_string(),
1194 source: RsyncSource::PreferredLocation,
1195 shadowed: Some(ShadowedRsync {
1196 path: PathBuf::from("/usr/bin/rsync"),
1197 flavor: RsyncFlavor::OpenRsync { protocol: Some(29) },
1198 }),
1199 };
1200 assert_eq!(
1201 resolved.describe(),
1202 "rsync 3.4.1 at /opt/homebrew/bin/rsync (well-known install location); \
1203 PATH rsync is openrsync (protocol 29) at /usr/bin/rsync"
1204 );
1205 }
1206
1207 #[test]
1208 fn override_path_expands_tilde_and_keeps_absolute_paths() {
1209 assert_eq!(
1210 override_path("/usr/bin/rsync"),
1211 Some(PathBuf::from("/usr/bin/rsync"))
1212 );
1213 let home = override_path("~/bin/rsync").expect("expanded");
1214 assert!(!home.to_string_lossy().starts_with('~'));
1215 assert!(home.ends_with("bin/rsync"));
1216 assert_eq!(override_path(" "), None);
1217 }
1218
1219 #[test]
1220 fn explicit_override_to_missing_binary_is_an_error() {
1221 let missing =
1222 std::env::temp_dir().join(format!("rch-rsync-missing-{}", uuid::Uuid::new_v4()));
1223 let error = resolve_rsync(Some(missing.to_str().expect("utf-8"))).expect_err("missing");
1224 assert_eq!(
1225 error,
1226 RsyncResolveError::OverrideMissing {
1227 origin: RsyncSource::Config,
1228 path: missing,
1229 }
1230 );
1231 }
1232
1233 #[cfg(unix)]
1234 #[test]
1235 fn explicit_override_is_honoured_whatever_its_flavour() {
1236 use std::os::unix::fs::PermissionsExt;
1237 let dir = tempfile::tempdir().expect("tempdir");
1238 let fake = dir.path().join("rsync");
1239 std::fs::write(
1240 &fake,
1241 "#!/bin/sh\necho 'openrsync: protocol version 29'\necho 'rsync version 2.6.9 compatible'\n",
1242 )
1243 .expect("write");
1244 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1245
1246 let resolved = resolve_rsync(Some(fake.to_str().expect("utf-8"))).expect("resolved");
1247 assert_eq!(resolved.path, fake);
1248 assert_eq!(resolved.source, RsyncSource::Config);
1249 assert_eq!(
1250 resolved.flavor,
1251 RsyncFlavor::OpenRsync { protocol: Some(29) }
1252 );
1253 assert_eq!(resolved.version_line, "openrsync: protocol version 29");
1254 assert!(resolved.capabilities().is_compatibility_mode());
1255 assert!(resolved.shadowed.is_none());
1256 }
1257
1258 #[cfg(unix)]
1259 #[test]
1260 fn probe_reports_binaries_that_print_nothing() {
1261 use std::os::unix::fs::PermissionsExt;
1262 let dir = tempfile::tempdir().expect("tempdir");
1263 let silent = dir.path().join("rsync");
1264 std::fs::write(&silent, "#!/bin/sh\nexit 0\n").expect("write");
1265 std::fs::set_permissions(&silent, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1266 let error = probe_rsync_version(&silent).expect_err("silent");
1267 assert!(
1268 matches!(error, RsyncResolveError::ProbeFailed { reason, .. } if reason == "no version output")
1269 );
1270 }
1271
1272 #[test]
1273 fn cached_resolution_is_stable_within_a_process() {
1274 clear_resolve_cache();
1275 let first = resolve_rsync_cached(None);
1276 let second = resolve_rsync_cached(None);
1277 assert_eq!(first, second);
1278 if let Ok(resolved) = first {
1279 assert!(resolved.path.is_absolute(), "{}", resolved.path.display());
1280 assert!(!resolved.version_line.is_empty());
1281 }
1282 }
1283}