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]
134 pub const fn supports_info_flags(self) -> bool {
135 self.capabilities().info_flags
136 }
137
138 #[must_use]
143 pub const fn is_supported(self) -> bool {
144 match self {
145 Self::Rsync {
146 major,
147 minor,
148 patch,
149 } => {
150 let (min_major, min_minor, min_patch) = MIN_SUPPORTED_RSYNC;
151 if major != min_major {
152 major > min_major
153 } else if minor != min_minor {
154 minor > min_minor
155 } else {
156 patch >= min_patch
157 }
158 }
159 Self::OpenRsync { .. } | Self::Unknown => true,
160 }
161 }
162
163 #[must_use]
169 pub const fn capabilities(self) -> RsyncCapabilities {
170 match self {
171 Self::Rsync { major, minor, .. } => RsyncCapabilities {
172 info_flags: version_at_least(major, minor, 3, 1),
175 stats_regular_file_breakdown: version_at_least(major, minor, 3, 1),
176 compress_choice: version_at_least(major, minor, 3, 2),
178 append_verify: version_at_least(major, minor, 3, 0),
180 no_motd: version_at_least(major, minor, 3, 0),
181 },
182 Self::OpenRsync { .. } => RsyncCapabilities {
183 info_flags: false,
184 stats_regular_file_breakdown: false,
185 compress_choice: false,
186 append_verify: false,
187 no_motd: true,
188 },
189 Self::Unknown => RsyncCapabilities::MODERN,
190 }
191 }
192}
193
194impl fmt::Display for RsyncFlavor {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 match self {
197 Self::Rsync {
198 major,
199 minor,
200 patch,
201 } => write!(f, "rsync {major}.{minor}.{patch}"),
202 Self::OpenRsync {
203 protocol: Some(protocol),
204 } => write!(f, "openrsync (protocol {protocol})"),
205 Self::OpenRsync { protocol: None } => f.write_str("openrsync"),
206 Self::Unknown => f.write_str("unrecognized rsync"),
207 }
208 }
209}
210
211const fn version_at_least(major: u32, minor: u32, want_major: u32, want_minor: u32) -> bool {
212 major > want_major || (major == want_major && minor >= want_minor)
213}
214
215fn parse_semver_prefix(token: &str) -> Option<(u32, u32, u32)> {
216 let numeric: String = token
219 .chars()
220 .take_while(|ch| ch.is_ascii_digit() || *ch == '.')
221 .collect();
222 let mut parts = numeric.split('.').filter(|part| !part.is_empty());
223 let major = parts.next()?.parse().ok()?;
224 let minor = parts.next().map_or(Some(0), |part| part.parse().ok())?;
225 let patch = parts.next().map_or(Some(0), |part| part.parse().ok())?;
226 Some((major, minor, patch))
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
232pub struct RsyncCapabilities {
233 pub info_flags: bool,
235 pub stats_regular_file_breakdown: bool,
239 pub compress_choice: bool,
242 pub append_verify: bool,
245 pub no_motd: bool,
247}
248
249impl RsyncCapabilities {
250 pub const MODERN: Self = Self {
252 info_flags: true,
253 stats_regular_file_breakdown: true,
254 compress_choice: true,
255 append_verify: true,
256 no_motd: true,
257 };
258
259 pub const LEGACY: Self = Self {
261 info_flags: false,
262 stats_regular_file_breakdown: false,
263 compress_choice: false,
264 append_verify: false,
265 no_motd: false,
266 };
267
268 #[must_use]
271 pub const fn is_compatibility_mode(&self) -> bool {
272 !self.info_flags
273 }
274
275 #[must_use]
280 pub const fn progress_args(&self) -> &'static [&'static str] {
281 if self.info_flags {
282 &["--info=progress2"]
283 } else {
284 &["--progress"]
285 }
286 }
287
288 #[must_use]
292 pub const fn stats_args(&self) -> &'static [&'static str] {
293 if self.info_flags {
294 &["--info=stats2"]
295 } else {
296 &["--stats"]
297 }
298 }
299
300 #[must_use]
306 pub const fn name_listing_args(&self) -> &'static [&'static str] {
307 if self.info_flags {
308 &["--info=name2"]
309 } else {
310 &["-vv"]
311 }
312 }
313
314 #[must_use]
320 pub fn compression_args(&self, level: u32) -> Vec<String> {
321 if level == 0 {
322 return Vec::new();
323 }
324 if self.compress_choice {
325 vec![
326 "--compress-choice=zstd".to_string(),
327 format!("--compress-level={level}"),
328 ]
329 } else {
330 vec![format!(
331 "--compress-level={}",
332 level.min(MAX_ZLIB_COMPRESS_LEVEL)
333 )]
334 }
335 }
336
337 #[must_use]
341 pub const fn resume_args(&self) -> &'static [&'static str] {
342 if self.append_verify {
343 &["--partial", "--append-verify"]
344 } else {
345 &["--partial"]
346 }
347 }
348
349 #[must_use]
352 pub const fn no_motd_args(&self) -> &'static [&'static str] {
353 if self.no_motd { &["--no-motd"] } else { &[] }
354 }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum RsyncSource {
361 Env,
363 Config,
365 Path,
367 PreferredLocation,
370}
371
372impl fmt::Display for RsyncSource {
373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374 f.write_str(match self {
375 Self::Env => RSYNC_BIN_ENV,
376 Self::Config => "[transfer] rsync_bin",
377 Self::Path => "PATH",
378 Self::PreferredLocation => "well-known install location",
379 })
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct ResolvedRsync {
386 pub path: PathBuf,
388 pub flavor: RsyncFlavor,
390 pub version_line: String,
392 pub source: RsyncSource,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub shadowed: Option<ShadowedRsync>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct ShadowedRsync {
404 pub path: PathBuf,
405 pub flavor: RsyncFlavor,
406}
407
408impl ResolvedRsync {
409 #[must_use]
411 pub const fn capabilities(&self) -> RsyncCapabilities {
412 self.flavor.capabilities()
413 }
414
415 #[must_use]
418 pub fn describe(&self) -> String {
419 let mut text = format!(
420 "{} at {} ({})",
421 self.flavor,
422 self.path.display(),
423 self.source
424 );
425 if let Some(shadowed) = &self.shadowed {
426 text.push_str(&format!(
427 "; PATH rsync is {} at {}",
428 shadowed.flavor,
429 shadowed.path.display()
430 ));
431 }
432 text
433 }
434}
435
436#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
438pub enum RsyncResolveError {
439 #[error("rsync not found on PATH or in {}", PREFERRED_RSYNC_LOCATIONS.join(", "))]
441 NotFound,
442 #[error("{origin} points at {path}, which is not an executable file")]
445 OverrideMissing { origin: RsyncSource, path: PathBuf },
446 #[error("{path} --version failed: {reason}")]
448 ProbeFailed { path: PathBuf, reason: String },
449}
450
451pub fn probe_rsync_version(path: &Path) -> Result<String, RsyncResolveError> {
454 let fail = |reason: String| RsyncResolveError::ProbeFailed {
455 path: path.to_path_buf(),
456 reason,
457 };
458 let mut child = Command::new(path)
459 .arg("--version")
460 .env("LC_ALL", "C")
461 .stdin(Stdio::null())
462 .stdout(Stdio::piped())
463 .stderr(Stdio::piped())
464 .spawn()
465 .map_err(|error| fail(error.to_string()))?;
466
467 let started = Instant::now();
468 loop {
469 match child.try_wait() {
470 Ok(Some(_)) => break,
471 Ok(None) if started.elapsed() >= VERSION_PROBE_TIMEOUT => {
472 let _ = child.kill();
473 let _ = child.wait();
474 return Err(fail(format!(
475 "timed out after {}s",
476 VERSION_PROBE_TIMEOUT.as_secs()
477 )));
478 }
479 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
480 Err(error) => return Err(fail(error.to_string())),
481 }
482 }
483 let output = child
484 .wait_with_output()
485 .map_err(|error| fail(error.to_string()))?;
486 let stdout = String::from_utf8_lossy(&output.stdout);
487 let stderr = String::from_utf8_lossy(&output.stderr);
488 let text = if stdout.trim().is_empty() {
489 stderr.as_ref()
490 } else {
491 stdout.as_ref()
492 };
493 text.lines()
494 .map(str::trim)
495 .find(|line| !line.is_empty())
496 .map(str::to_string)
497 .ok_or_else(|| fail("no version output".to_string()))
498}
499
500fn is_executable_file(path: &Path) -> bool {
501 let Ok(metadata) = std::fs::metadata(path) else {
502 return false;
503 };
504 if !metadata.is_file() {
505 return false;
506 }
507 #[cfg(unix)]
508 {
509 use std::os::unix::fs::PermissionsExt;
510 metadata.permissions().mode() & 0o111 != 0
511 }
512 #[cfg(not(unix))]
513 {
514 true
515 }
516}
517
518fn find_in_path(name: &str) -> Option<PathBuf> {
520 let path_var = std::env::var_os("PATH")?;
521 let names: Vec<String> = if cfg!(windows) {
522 vec![format!("{name}.exe"), name.to_string()]
523 } else {
524 vec![name.to_string()]
525 };
526 std::env::split_paths(&path_var)
527 .filter(|dir| !dir.as_os_str().is_empty())
528 .flat_map(|dir| names.iter().map(move |n| dir.join(n)))
529 .find(|candidate| is_executable_file(candidate))
530}
531
532fn override_path(value: &str) -> Option<PathBuf> {
535 let expanded = shellexpand::tilde(value.trim()).into_owned();
536 if expanded.is_empty() {
537 return None;
538 }
539 let path = PathBuf::from(&expanded);
540 if path.components().count() > 1 || path.is_absolute() {
541 Some(path)
542 } else {
543 find_in_path(&expanded).or(Some(path))
544 }
545}
546
547pub fn resolve_rsync(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
555 let env_override = std::env::var(RSYNC_BIN_ENV)
556 .ok()
557 .filter(|value| !value.trim().is_empty());
558 let explicit = env_override
559 .as_deref()
560 .map(|value| (value, RsyncSource::Env))
561 .or_else(|| {
562 configured
563 .filter(|value| !value.trim().is_empty())
564 .map(|value| (value, RsyncSource::Config))
565 });
566
567 if let Some((value, source)) = explicit {
568 let path = override_path(value).ok_or_else(|| RsyncResolveError::OverrideMissing {
569 origin: source,
570 path: PathBuf::from(value),
571 })?;
572 if !is_executable_file(&path) {
573 return Err(RsyncResolveError::OverrideMissing {
574 origin: source,
575 path,
576 });
577 }
578 let version_line = probe_rsync_version(&path)?;
579 return Ok(ResolvedRsync {
580 flavor: RsyncFlavor::parse_version_output(&version_line),
581 path,
582 version_line,
583 source,
584 shadowed: None,
585 });
586 }
587
588 let path_candidate = find_in_path("rsync").map(|path| {
589 let probed = probe_rsync_version(&path);
590 (path, probed)
591 });
592
593 if let Some((path, Ok(version_line))) = &path_candidate {
595 let flavor = RsyncFlavor::parse_version_output(version_line);
596 if flavor.supports_info_flags() {
597 return Ok(ResolvedRsync {
598 path: path.clone(),
599 flavor,
600 version_line: version_line.clone(),
601 source: RsyncSource::Path,
602 shadowed: None,
603 });
604 }
605 }
606
607 for location in PREFERRED_RSYNC_LOCATIONS {
610 let path = PathBuf::from(location);
611 if path_candidate
612 .as_ref()
613 .is_some_and(|(path_path, _)| same_file(path_path, &path))
614 || !is_executable_file(&path)
615 {
616 continue;
617 }
618 let Ok(version_line) = probe_rsync_version(&path) else {
619 continue;
620 };
621 let flavor = RsyncFlavor::parse_version_output(&version_line);
622 if !flavor.supports_info_flags() {
623 continue;
624 }
625 let shadowed = path_candidate
626 .as_ref()
627 .map(|(path_path, probed)| ShadowedRsync {
628 path: path_path.clone(),
629 flavor: probed
630 .as_deref()
631 .map_or(RsyncFlavor::Unknown, RsyncFlavor::parse_version_output),
632 });
633 return Ok(ResolvedRsync {
634 path,
635 flavor,
636 version_line,
637 source: RsyncSource::PreferredLocation,
638 shadowed,
639 });
640 }
641
642 match path_candidate {
645 Some((path, Ok(version_line))) => Ok(ResolvedRsync {
646 flavor: RsyncFlavor::parse_version_output(&version_line),
647 path,
648 version_line,
649 source: RsyncSource::Path,
650 shadowed: None,
651 }),
652 Some((_, Err(error))) => Err(error),
653 None => Err(RsyncResolveError::NotFound),
654 }
655}
656
657fn same_file(a: &Path, b: &Path) -> bool {
658 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
659 (Ok(a), Ok(b)) => a == b,
660 _ => a == b,
661 }
662}
663
664type ResolveCache = Mutex<HashMap<Option<String>, Result<ResolvedRsync, RsyncResolveError>>>;
665
666fn resolve_cache() -> &'static ResolveCache {
667 static CACHE: OnceLock<ResolveCache> = OnceLock::new();
668 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
669}
670
671pub fn resolve_rsync_cached(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
677 let key = configured.map(str::to_string);
678 if let Ok(cache) = resolve_cache().lock()
679 && let Some(cached) = cache.get(&key)
680 {
681 return cached.clone();
682 }
683 let resolved = resolve_rsync(configured);
684 if let Ok(mut cache) = resolve_cache().lock() {
685 cache.insert(key, resolved.clone());
686 }
687 resolved
688}
689
690pub fn clear_resolve_cache() {
693 if let Ok(mut cache) = resolve_cache().lock() {
694 cache.clear();
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 const RSYNC_3_4_1: &str = "rsync version 3.4.1 protocol version 32\n\
704Copyright (C) 1996-2025 by Andrew Tridgell, Wayne Davison, and others.\n\
705Web site: https://rsync.samba.org/\n\
706Capabilities:\n 64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,\n";
707
708 const RSYNC_3_2_7: &str = "rsync version 3.2.7 protocol version 31\n\
710Copyright (C) 1996-2022 by Andrew Tridgell, Wayne Davison, and others.\n";
711
712 const OPENRSYNC_MACOS: &str =
714 "openrsync: protocol version 29\nrsync version 2.6.9 compatible\n";
715
716 const RSYNC_2_6_9_APPLE: &str = "rsync version 2.6.9 protocol version 29\n\
718Copyright (C) 1996-2006 by Andrew Tridgell, Wayne Davison, and others.\n";
719
720 #[test]
721 fn parses_upstream_3x_versions() {
722 assert_eq!(
723 RsyncFlavor::parse_version_output(RSYNC_3_4_1),
724 RsyncFlavor::Rsync {
725 major: 3,
726 minor: 4,
727 patch: 1
728 }
729 );
730 assert_eq!(
731 RsyncFlavor::parse_version_output(RSYNC_3_2_7),
732 RsyncFlavor::Rsync {
733 major: 3,
734 minor: 2,
735 patch: 7
736 }
737 );
738 assert_eq!(
739 RsyncFlavor::parse_version_output("rsync version 3.2.7pre1 protocol version 31"),
740 RsyncFlavor::Rsync {
741 major: 3,
742 minor: 2,
743 patch: 7
744 }
745 );
746 assert_eq!(
747 RsyncFlavor::parse_version_output("rsync version 3.5.0dev protocol version 32"),
748 RsyncFlavor::Rsync {
749 major: 3,
750 minor: 5,
751 patch: 0
752 }
753 );
754 }
755
756 #[test]
757 fn parses_openrsync_and_does_not_mistake_its_compat_line_for_2_6_9() {
758 assert_eq!(
759 RsyncFlavor::parse_version_output(OPENRSYNC_MACOS),
760 RsyncFlavor::OpenRsync { protocol: Some(29) }
761 );
762 assert_eq!(
763 RsyncFlavor::parse_version_output("openrsync: protocol version"),
764 RsyncFlavor::OpenRsync { protocol: None }
765 );
766 }
767
768 #[test]
769 fn parses_apple_legacy_2_6_9() {
770 assert_eq!(
771 RsyncFlavor::parse_version_output(RSYNC_2_6_9_APPLE),
772 RsyncFlavor::Rsync {
773 major: 2,
774 minor: 6,
775 patch: 9
776 }
777 );
778 }
779
780 #[test]
781 fn unrecognized_banner_is_unknown_and_assumed_modern() {
782 for text in [
783 "",
784 "\n\n",
785 "rsync: command not found",
786 "usage: rsync [-0468BC...]",
787 ] {
788 assert_eq!(
789 RsyncFlavor::parse_version_output(text),
790 RsyncFlavor::Unknown,
791 "{text:?}"
792 );
793 }
794 assert_eq!(
795 RsyncFlavor::Unknown.capabilities(),
796 RsyncCapabilities::MODERN
797 );
798 assert!(RsyncFlavor::Unknown.is_supported());
799 }
800
801 #[test]
802 fn capabilities_follow_version_thresholds() {
803 let v = |major, minor| RsyncFlavor::Rsync {
804 major,
805 minor,
806 patch: 0,
807 };
808 assert_eq!(v(3, 4).capabilities(), RsyncCapabilities::MODERN);
809 assert_eq!(v(3, 2).capabilities(), RsyncCapabilities::MODERN);
810 let three_one = v(3, 1).capabilities();
811 assert!(three_one.info_flags);
812 assert!(three_one.stats_regular_file_breakdown);
813 assert!(!three_one.compress_choice);
814 assert!(three_one.append_verify);
815 let three_zero = v(3, 0).capabilities();
816 assert!(!three_zero.info_flags);
817 assert!(!three_zero.compress_choice);
818 assert!(three_zero.append_verify);
819 assert!(three_zero.no_motd);
820 assert_eq!(v(2, 6).capabilities(), RsyncCapabilities::LEGACY);
821 let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
822 assert!(!openrsync.info_flags);
823 assert!(!openrsync.compress_choice);
824 assert!(!openrsync.append_verify);
825 assert!(openrsync.no_motd);
826 assert!(openrsync.is_compatibility_mode());
827 assert!(!RsyncCapabilities::MODERN.is_compatibility_mode());
828 }
829
830 #[test]
831 fn support_floor_is_2_6_9() {
832 let v = |major, minor, patch| RsyncFlavor::Rsync {
833 major,
834 minor,
835 patch,
836 };
837 assert!(v(2, 6, 9).is_supported());
838 assert!(v(3, 0, 0).is_supported());
839 assert!(!v(2, 6, 8).is_supported());
840 assert!(!v(2, 5, 7).is_supported());
841 assert!(RsyncFlavor::OpenRsync { protocol: None }.is_supported());
842 }
843
844 #[test]
845 fn argv_fragments_per_flavour() {
846 let modern = RsyncCapabilities::MODERN;
847 assert_eq!(modern.progress_args(), ["--info=progress2"]);
848 assert_eq!(modern.stats_args(), ["--info=stats2"]);
849 assert_eq!(modern.name_listing_args(), ["--info=name2"]);
850 assert_eq!(modern.resume_args(), ["--partial", "--append-verify"]);
851 assert_eq!(modern.no_motd_args(), ["--no-motd"]);
852 assert_eq!(
853 modern.compression_args(7),
854 ["--compress-choice=zstd", "--compress-level=7"]
855 );
856 assert_eq!(
857 modern.compression_args(19),
858 ["--compress-choice=zstd", "--compress-level=19"]
859 );
860 assert!(modern.compression_args(0).is_empty());
861
862 let legacy = RsyncCapabilities::LEGACY;
863 assert_eq!(legacy.progress_args(), ["--progress"]);
864 assert_eq!(legacy.stats_args(), ["--stats"]);
865 assert_eq!(legacy.name_listing_args(), ["-vv"]);
866 assert_eq!(legacy.resume_args(), ["--partial"]);
867 assert!(legacy.no_motd_args().is_empty());
868 assert_eq!(legacy.compression_args(7), ["--compress-level=7"]);
869 assert_eq!(legacy.compression_args(19), ["--compress-level=9"]);
871 assert!(legacy.compression_args(0).is_empty());
872
873 let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
874 assert_eq!(openrsync.no_motd_args(), ["--no-motd"]);
875 assert_eq!(openrsync.progress_args(), ["--progress"]);
876 }
877
878 #[test]
879 fn display_and_describe() {
880 assert_eq!(
881 RsyncFlavor::Rsync {
882 major: 3,
883 minor: 4,
884 patch: 1
885 }
886 .to_string(),
887 "rsync 3.4.1"
888 );
889 assert_eq!(
890 RsyncFlavor::OpenRsync { protocol: Some(29) }.to_string(),
891 "openrsync (protocol 29)"
892 );
893 let resolved = ResolvedRsync {
894 path: PathBuf::from("/opt/homebrew/bin/rsync"),
895 flavor: RsyncFlavor::Rsync {
896 major: 3,
897 minor: 4,
898 patch: 1,
899 },
900 version_line: "rsync version 3.4.1 protocol version 32".to_string(),
901 source: RsyncSource::PreferredLocation,
902 shadowed: Some(ShadowedRsync {
903 path: PathBuf::from("/usr/bin/rsync"),
904 flavor: RsyncFlavor::OpenRsync { protocol: Some(29) },
905 }),
906 };
907 assert_eq!(
908 resolved.describe(),
909 "rsync 3.4.1 at /opt/homebrew/bin/rsync (well-known install location); \
910 PATH rsync is openrsync (protocol 29) at /usr/bin/rsync"
911 );
912 }
913
914 #[test]
915 fn override_path_expands_tilde_and_keeps_absolute_paths() {
916 assert_eq!(
917 override_path("/usr/bin/rsync"),
918 Some(PathBuf::from("/usr/bin/rsync"))
919 );
920 let home = override_path("~/bin/rsync").expect("expanded");
921 assert!(!home.to_string_lossy().starts_with('~'));
922 assert!(home.ends_with("bin/rsync"));
923 assert_eq!(override_path(" "), None);
924 }
925
926 #[test]
927 fn explicit_override_to_missing_binary_is_an_error() {
928 let missing =
929 std::env::temp_dir().join(format!("rch-rsync-missing-{}", uuid::Uuid::new_v4()));
930 let error = resolve_rsync(Some(missing.to_str().expect("utf-8"))).expect_err("missing");
931 assert_eq!(
932 error,
933 RsyncResolveError::OverrideMissing {
934 origin: RsyncSource::Config,
935 path: missing,
936 }
937 );
938 }
939
940 #[cfg(unix)]
941 #[test]
942 fn explicit_override_is_honoured_whatever_its_flavour() {
943 use std::os::unix::fs::PermissionsExt;
944 let dir = tempfile::tempdir().expect("tempdir");
945 let fake = dir.path().join("rsync");
946 std::fs::write(
947 &fake,
948 "#!/bin/sh\necho 'openrsync: protocol version 29'\necho 'rsync version 2.6.9 compatible'\n",
949 )
950 .expect("write");
951 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).expect("chmod");
952
953 let resolved = resolve_rsync(Some(fake.to_str().expect("utf-8"))).expect("resolved");
954 assert_eq!(resolved.path, fake);
955 assert_eq!(resolved.source, RsyncSource::Config);
956 assert_eq!(
957 resolved.flavor,
958 RsyncFlavor::OpenRsync { protocol: Some(29) }
959 );
960 assert_eq!(resolved.version_line, "openrsync: protocol version 29");
961 assert!(resolved.capabilities().is_compatibility_mode());
962 assert!(resolved.shadowed.is_none());
963 }
964
965 #[cfg(unix)]
966 #[test]
967 fn probe_reports_binaries_that_print_nothing() {
968 use std::os::unix::fs::PermissionsExt;
969 let dir = tempfile::tempdir().expect("tempdir");
970 let silent = dir.path().join("rsync");
971 std::fs::write(&silent, "#!/bin/sh\nexit 0\n").expect("write");
972 std::fs::set_permissions(&silent, std::fs::Permissions::from_mode(0o755)).expect("chmod");
973 let error = probe_rsync_version(&silent).expect_err("silent");
974 assert!(
975 matches!(error, RsyncResolveError::ProbeFailed { reason, .. } if reason == "no version output")
976 );
977 }
978
979 #[test]
980 fn cached_resolution_is_stable_within_a_process() {
981 clear_resolve_cache();
982 let first = resolve_rsync_cached(None);
983 let second = resolve_rsync_cached(None);
984 assert_eq!(first, second);
985 if let Ok(resolved) = first {
986 assert!(resolved.path.is_absolute(), "{}", resolved.path.display());
987 assert!(!resolved.version_line.is_empty());
988 }
989 }
990}