use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
pub const RSYNC_BIN_ENV: &str = "RCH_RSYNC_BIN";
pub const PREFERRED_RSYNC_LOCATIONS: &[&str] = &[
"/opt/homebrew/bin/rsync",
"/usr/local/bin/rsync",
"/opt/local/bin/rsync",
"/usr/local/opt/rsync/bin/rsync",
"/opt/homebrew/opt/rsync/bin/rsync",
];
pub const MIN_SUPPORTED_RSYNC: (u32, u32, u32) = (2, 6, 9);
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_ZLIB_COMPRESS_LEVEL: u32 = 9;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RsyncFlavor {
Rsync { major: u32, minor: u32, patch: u32 },
OpenRsync {
protocol: Option<u32>,
},
Unknown,
}
impl RsyncFlavor {
#[must_use]
pub fn parse_version_output(text: &str) -> Self {
let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
let Some(first) = lines.next() else {
return Self::Unknown;
};
let lowered = first.to_ascii_lowercase();
if lowered.starts_with("openrsync") {
let protocol = lowered
.split("protocol version")
.nth(1)
.and_then(|rest| rest.split_whitespace().next())
.and_then(|token| token.parse().ok());
return Self::OpenRsync { protocol };
}
if lowered.starts_with("rsync")
&& let Some(rest) = lowered.split("version").nth(1)
&& let Some(token) = rest.split_whitespace().next()
&& let Some((major, minor, patch)) = parse_semver_prefix(token)
{
return Self::Rsync {
major,
minor,
patch,
};
}
Self::Unknown
}
#[must_use]
pub const fn supports_info_flags(self) -> bool {
self.capabilities().info_flags
}
#[must_use]
pub const fn is_supported(self) -> bool {
match self {
Self::Rsync {
major,
minor,
patch,
} => {
let (min_major, min_minor, min_patch) = MIN_SUPPORTED_RSYNC;
if major != min_major {
major > min_major
} else if minor != min_minor {
minor > min_minor
} else {
patch >= min_patch
}
}
Self::OpenRsync { .. } | Self::Unknown => true,
}
}
#[must_use]
pub const fn capabilities(self) -> RsyncCapabilities {
match self {
Self::Rsync { major, minor, .. } => RsyncCapabilities {
info_flags: version_at_least(major, minor, 3, 1),
stats_regular_file_breakdown: version_at_least(major, minor, 3, 1),
compress_choice: version_at_least(major, minor, 3, 2),
append_verify: version_at_least(major, minor, 3, 0),
no_motd: version_at_least(major, minor, 3, 0),
},
Self::OpenRsync { .. } => RsyncCapabilities {
info_flags: false,
stats_regular_file_breakdown: false,
compress_choice: false,
append_verify: false,
no_motd: true,
},
Self::Unknown => RsyncCapabilities::MODERN,
}
}
}
impl fmt::Display for RsyncFlavor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rsync {
major,
minor,
patch,
} => write!(f, "rsync {major}.{minor}.{patch}"),
Self::OpenRsync {
protocol: Some(protocol),
} => write!(f, "openrsync (protocol {protocol})"),
Self::OpenRsync { protocol: None } => f.write_str("openrsync"),
Self::Unknown => f.write_str("unrecognized rsync"),
}
}
}
const fn version_at_least(major: u32, minor: u32, want_major: u32, want_minor: u32) -> bool {
major > want_major || (major == want_major && minor >= want_minor)
}
fn parse_semver_prefix(token: &str) -> Option<(u32, u32, u32)> {
let numeric: String = token
.chars()
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
.collect();
let mut parts = numeric.split('.').filter(|part| !part.is_empty());
let major = parts.next()?.parse().ok()?;
let minor = parts.next().map_or(Some(0), |part| part.parse().ok())?;
let patch = parts.next().map_or(Some(0), |part| part.parse().ok())?;
Some((major, minor, patch))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RsyncCapabilities {
pub info_flags: bool,
pub stats_regular_file_breakdown: bool,
pub compress_choice: bool,
pub append_verify: bool,
pub no_motd: bool,
}
impl RsyncCapabilities {
pub const MODERN: Self = Self {
info_flags: true,
stats_regular_file_breakdown: true,
compress_choice: true,
append_verify: true,
no_motd: true,
};
pub const LEGACY: Self = Self {
info_flags: false,
stats_regular_file_breakdown: false,
compress_choice: false,
append_verify: false,
no_motd: false,
};
#[must_use]
pub const fn is_compatibility_mode(&self) -> bool {
!self.info_flags
}
#[must_use]
pub const fn progress_args(&self) -> &'static [&'static str] {
if self.info_flags {
&["--info=progress2"]
} else {
&["--progress"]
}
}
#[must_use]
pub const fn stats_args(&self) -> &'static [&'static str] {
if self.info_flags {
&["--info=stats2"]
} else {
&["--stats"]
}
}
#[must_use]
pub const fn name_listing_args(&self) -> &'static [&'static str] {
if self.info_flags {
&["--info=name2"]
} else {
&["-vv"]
}
}
#[must_use]
pub fn compression_args(&self, level: u32) -> Vec<String> {
if level == 0 {
return Vec::new();
}
if self.compress_choice {
vec![
"--compress-choice=zstd".to_string(),
format!("--compress-level={level}"),
]
} else {
vec![format!(
"--compress-level={}",
level.min(MAX_ZLIB_COMPRESS_LEVEL)
)]
}
}
#[must_use]
pub const fn resume_args(&self) -> &'static [&'static str] {
if self.append_verify {
&["--partial", "--append-verify"]
} else {
&["--partial"]
}
}
#[must_use]
pub const fn no_motd_args(&self) -> &'static [&'static str] {
if self.no_motd { &["--no-motd"] } else { &[] }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RsyncSource {
Env,
Config,
Path,
PreferredLocation,
}
impl fmt::Display for RsyncSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Env => RSYNC_BIN_ENV,
Self::Config => "[transfer] rsync_bin",
Self::Path => "PATH",
Self::PreferredLocation => "well-known install location",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedRsync {
pub path: PathBuf,
pub flavor: RsyncFlavor,
pub version_line: String,
pub source: RsyncSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shadowed: Option<ShadowedRsync>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShadowedRsync {
pub path: PathBuf,
pub flavor: RsyncFlavor,
}
impl ResolvedRsync {
#[must_use]
pub const fn capabilities(&self) -> RsyncCapabilities {
self.flavor.capabilities()
}
#[must_use]
pub fn describe(&self) -> String {
let mut text = format!(
"{} at {} ({})",
self.flavor,
self.path.display(),
self.source
);
if let Some(shadowed) = &self.shadowed {
text.push_str(&format!(
"; PATH rsync is {} at {}",
shadowed.flavor,
shadowed.path.display()
));
}
text
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RsyncResolveError {
#[error("rsync not found on PATH or in {}", PREFERRED_RSYNC_LOCATIONS.join(", "))]
NotFound,
#[error("{origin} points at {path}, which is not an executable file")]
OverrideMissing { origin: RsyncSource, path: PathBuf },
#[error("{path} --version failed: {reason}")]
ProbeFailed { path: PathBuf, reason: String },
}
pub fn probe_rsync_version(path: &Path) -> Result<String, RsyncResolveError> {
let fail = |reason: String| RsyncResolveError::ProbeFailed {
path: path.to_path_buf(),
reason,
};
let mut child = Command::new(path)
.arg("--version")
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| fail(error.to_string()))?;
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if started.elapsed() >= VERSION_PROBE_TIMEOUT => {
let _ = child.kill();
let _ = child.wait();
return Err(fail(format!(
"timed out after {}s",
VERSION_PROBE_TIMEOUT.as_secs()
)));
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(error) => return Err(fail(error.to_string())),
}
}
let output = child
.wait_with_output()
.map_err(|error| fail(error.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let text = if stdout.trim().is_empty() {
stderr.as_ref()
} else {
stdout.as_ref()
};
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
.ok_or_else(|| fail("no version output".to_string()))
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn find_in_path(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
let names: Vec<String> = if cfg!(windows) {
vec![format!("{name}.exe"), name.to_string()]
} else {
vec![name.to_string()]
};
std::env::split_paths(&path_var)
.filter(|dir| !dir.as_os_str().is_empty())
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
.find(|candidate| is_executable_file(candidate))
}
fn override_path(value: &str) -> Option<PathBuf> {
let expanded = shellexpand::tilde(value.trim()).into_owned();
if expanded.is_empty() {
return None;
}
let path = PathBuf::from(&expanded);
if path.components().count() > 1 || path.is_absolute() {
Some(path)
} else {
find_in_path(&expanded).or(Some(path))
}
}
pub fn resolve_rsync(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
let env_override = std::env::var(RSYNC_BIN_ENV)
.ok()
.filter(|value| !value.trim().is_empty());
let explicit = env_override
.as_deref()
.map(|value| (value, RsyncSource::Env))
.or_else(|| {
configured
.filter(|value| !value.trim().is_empty())
.map(|value| (value, RsyncSource::Config))
});
if let Some((value, source)) = explicit {
let path = override_path(value).ok_or_else(|| RsyncResolveError::OverrideMissing {
origin: source,
path: PathBuf::from(value),
})?;
if !is_executable_file(&path) {
return Err(RsyncResolveError::OverrideMissing {
origin: source,
path,
});
}
let version_line = probe_rsync_version(&path)?;
return Ok(ResolvedRsync {
flavor: RsyncFlavor::parse_version_output(&version_line),
path,
version_line,
source,
shadowed: None,
});
}
let path_candidate = find_in_path("rsync").map(|path| {
let probed = probe_rsync_version(&path);
(path, probed)
});
if let Some((path, Ok(version_line))) = &path_candidate {
let flavor = RsyncFlavor::parse_version_output(version_line);
if flavor.supports_info_flags() {
return Ok(ResolvedRsync {
path: path.clone(),
flavor,
version_line: version_line.clone(),
source: RsyncSource::Path,
shadowed: None,
});
}
}
for location in PREFERRED_RSYNC_LOCATIONS {
let path = PathBuf::from(location);
if path_candidate
.as_ref()
.is_some_and(|(path_path, _)| same_file(path_path, &path))
|| !is_executable_file(&path)
{
continue;
}
let Ok(version_line) = probe_rsync_version(&path) else {
continue;
};
let flavor = RsyncFlavor::parse_version_output(&version_line);
if !flavor.supports_info_flags() {
continue;
}
let shadowed = path_candidate
.as_ref()
.map(|(path_path, probed)| ShadowedRsync {
path: path_path.clone(),
flavor: probed
.as_deref()
.map_or(RsyncFlavor::Unknown, RsyncFlavor::parse_version_output),
});
return Ok(ResolvedRsync {
path,
flavor,
version_line,
source: RsyncSource::PreferredLocation,
shadowed,
});
}
match path_candidate {
Some((path, Ok(version_line))) => Ok(ResolvedRsync {
flavor: RsyncFlavor::parse_version_output(&version_line),
path,
version_line,
source: RsyncSource::Path,
shadowed: None,
}),
Some((_, Err(error))) => Err(error),
None => Err(RsyncResolveError::NotFound),
}
}
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
type ResolveCache = Mutex<HashMap<Option<String>, Result<ResolvedRsync, RsyncResolveError>>>;
fn resolve_cache() -> &'static ResolveCache {
static CACHE: OnceLock<ResolveCache> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn resolve_rsync_cached(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
let key = configured.map(str::to_string);
if let Ok(cache) = resolve_cache().lock()
&& let Some(cached) = cache.get(&key)
{
return cached.clone();
}
let resolved = resolve_rsync(configured);
if let Ok(mut cache) = resolve_cache().lock() {
cache.insert(key, resolved.clone());
}
resolved
}
pub fn clear_resolve_cache() {
if let Ok(mut cache) = resolve_cache().lock() {
cache.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
const RSYNC_3_4_1: &str = "rsync version 3.4.1 protocol version 32\n\
Copyright (C) 1996-2025 by Andrew Tridgell, Wayne Davison, and others.\n\
Web site: https://rsync.samba.org/\n\
Capabilities:\n 64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,\n";
const RSYNC_3_2_7: &str = "rsync version 3.2.7 protocol version 31\n\
Copyright (C) 1996-2022 by Andrew Tridgell, Wayne Davison, and others.\n";
const OPENRSYNC_MACOS: &str =
"openrsync: protocol version 29\nrsync version 2.6.9 compatible\n";
const RSYNC_2_6_9_APPLE: &str = "rsync version 2.6.9 protocol version 29\n\
Copyright (C) 1996-2006 by Andrew Tridgell, Wayne Davison, and others.\n";
#[test]
fn parses_upstream_3x_versions() {
assert_eq!(
RsyncFlavor::parse_version_output(RSYNC_3_4_1),
RsyncFlavor::Rsync {
major: 3,
minor: 4,
patch: 1
}
);
assert_eq!(
RsyncFlavor::parse_version_output(RSYNC_3_2_7),
RsyncFlavor::Rsync {
major: 3,
minor: 2,
patch: 7
}
);
assert_eq!(
RsyncFlavor::parse_version_output("rsync version 3.2.7pre1 protocol version 31"),
RsyncFlavor::Rsync {
major: 3,
minor: 2,
patch: 7
}
);
assert_eq!(
RsyncFlavor::parse_version_output("rsync version 3.5.0dev protocol version 32"),
RsyncFlavor::Rsync {
major: 3,
minor: 5,
patch: 0
}
);
}
#[test]
fn parses_openrsync_and_does_not_mistake_its_compat_line_for_2_6_9() {
assert_eq!(
RsyncFlavor::parse_version_output(OPENRSYNC_MACOS),
RsyncFlavor::OpenRsync { protocol: Some(29) }
);
assert_eq!(
RsyncFlavor::parse_version_output("openrsync: protocol version"),
RsyncFlavor::OpenRsync { protocol: None }
);
}
#[test]
fn parses_apple_legacy_2_6_9() {
assert_eq!(
RsyncFlavor::parse_version_output(RSYNC_2_6_9_APPLE),
RsyncFlavor::Rsync {
major: 2,
minor: 6,
patch: 9
}
);
}
#[test]
fn unrecognized_banner_is_unknown_and_assumed_modern() {
for text in [
"",
"\n\n",
"rsync: command not found",
"usage: rsync [-0468BC...]",
] {
assert_eq!(
RsyncFlavor::parse_version_output(text),
RsyncFlavor::Unknown,
"{text:?}"
);
}
assert_eq!(
RsyncFlavor::Unknown.capabilities(),
RsyncCapabilities::MODERN
);
assert!(RsyncFlavor::Unknown.is_supported());
}
#[test]
fn capabilities_follow_version_thresholds() {
let v = |major, minor| RsyncFlavor::Rsync {
major,
minor,
patch: 0,
};
assert_eq!(v(3, 4).capabilities(), RsyncCapabilities::MODERN);
assert_eq!(v(3, 2).capabilities(), RsyncCapabilities::MODERN);
let three_one = v(3, 1).capabilities();
assert!(three_one.info_flags);
assert!(three_one.stats_regular_file_breakdown);
assert!(!three_one.compress_choice);
assert!(three_one.append_verify);
let three_zero = v(3, 0).capabilities();
assert!(!three_zero.info_flags);
assert!(!three_zero.compress_choice);
assert!(three_zero.append_verify);
assert!(three_zero.no_motd);
assert_eq!(v(2, 6).capabilities(), RsyncCapabilities::LEGACY);
let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
assert!(!openrsync.info_flags);
assert!(!openrsync.compress_choice);
assert!(!openrsync.append_verify);
assert!(openrsync.no_motd);
assert!(openrsync.is_compatibility_mode());
assert!(!RsyncCapabilities::MODERN.is_compatibility_mode());
}
#[test]
fn support_floor_is_2_6_9() {
let v = |major, minor, patch| RsyncFlavor::Rsync {
major,
minor,
patch,
};
assert!(v(2, 6, 9).is_supported());
assert!(v(3, 0, 0).is_supported());
assert!(!v(2, 6, 8).is_supported());
assert!(!v(2, 5, 7).is_supported());
assert!(RsyncFlavor::OpenRsync { protocol: None }.is_supported());
}
#[test]
fn argv_fragments_per_flavour() {
let modern = RsyncCapabilities::MODERN;
assert_eq!(modern.progress_args(), ["--info=progress2"]);
assert_eq!(modern.stats_args(), ["--info=stats2"]);
assert_eq!(modern.name_listing_args(), ["--info=name2"]);
assert_eq!(modern.resume_args(), ["--partial", "--append-verify"]);
assert_eq!(modern.no_motd_args(), ["--no-motd"]);
assert_eq!(
modern.compression_args(7),
["--compress-choice=zstd", "--compress-level=7"]
);
assert_eq!(
modern.compression_args(19),
["--compress-choice=zstd", "--compress-level=19"]
);
assert!(modern.compression_args(0).is_empty());
let legacy = RsyncCapabilities::LEGACY;
assert_eq!(legacy.progress_args(), ["--progress"]);
assert_eq!(legacy.stats_args(), ["--stats"]);
assert_eq!(legacy.name_listing_args(), ["-vv"]);
assert_eq!(legacy.resume_args(), ["--partial"]);
assert!(legacy.no_motd_args().is_empty());
assert_eq!(legacy.compression_args(7), ["--compress-level=7"]);
assert_eq!(legacy.compression_args(19), ["--compress-level=9"]);
assert!(legacy.compression_args(0).is_empty());
let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
assert_eq!(openrsync.no_motd_args(), ["--no-motd"]);
assert_eq!(openrsync.progress_args(), ["--progress"]);
}
#[test]
fn display_and_describe() {
assert_eq!(
RsyncFlavor::Rsync {
major: 3,
minor: 4,
patch: 1
}
.to_string(),
"rsync 3.4.1"
);
assert_eq!(
RsyncFlavor::OpenRsync { protocol: Some(29) }.to_string(),
"openrsync (protocol 29)"
);
let resolved = ResolvedRsync {
path: PathBuf::from("/opt/homebrew/bin/rsync"),
flavor: RsyncFlavor::Rsync {
major: 3,
minor: 4,
patch: 1,
},
version_line: "rsync version 3.4.1 protocol version 32".to_string(),
source: RsyncSource::PreferredLocation,
shadowed: Some(ShadowedRsync {
path: PathBuf::from("/usr/bin/rsync"),
flavor: RsyncFlavor::OpenRsync { protocol: Some(29) },
}),
};
assert_eq!(
resolved.describe(),
"rsync 3.4.1 at /opt/homebrew/bin/rsync (well-known install location); \
PATH rsync is openrsync (protocol 29) at /usr/bin/rsync"
);
}
#[test]
fn override_path_expands_tilde_and_keeps_absolute_paths() {
assert_eq!(
override_path("/usr/bin/rsync"),
Some(PathBuf::from("/usr/bin/rsync"))
);
let home = override_path("~/bin/rsync").expect("expanded");
assert!(!home.to_string_lossy().starts_with('~'));
assert!(home.ends_with("bin/rsync"));
assert_eq!(override_path(" "), None);
}
#[test]
fn explicit_override_to_missing_binary_is_an_error() {
let missing =
std::env::temp_dir().join(format!("rch-rsync-missing-{}", uuid::Uuid::new_v4()));
let error = resolve_rsync(Some(missing.to_str().expect("utf-8"))).expect_err("missing");
assert_eq!(
error,
RsyncResolveError::OverrideMissing {
origin: RsyncSource::Config,
path: missing,
}
);
}
#[cfg(unix)]
#[test]
fn explicit_override_is_honoured_whatever_its_flavour() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let fake = dir.path().join("rsync");
std::fs::write(
&fake,
"#!/bin/sh\necho 'openrsync: protocol version 29'\necho 'rsync version 2.6.9 compatible'\n",
)
.expect("write");
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let resolved = resolve_rsync(Some(fake.to_str().expect("utf-8"))).expect("resolved");
assert_eq!(resolved.path, fake);
assert_eq!(resolved.source, RsyncSource::Config);
assert_eq!(
resolved.flavor,
RsyncFlavor::OpenRsync { protocol: Some(29) }
);
assert_eq!(resolved.version_line, "openrsync: protocol version 29");
assert!(resolved.capabilities().is_compatibility_mode());
assert!(resolved.shadowed.is_none());
}
#[cfg(unix)]
#[test]
fn probe_reports_binaries_that_print_nothing() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let silent = dir.path().join("rsync");
std::fs::write(&silent, "#!/bin/sh\nexit 0\n").expect("write");
std::fs::set_permissions(&silent, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let error = probe_rsync_version(&silent).expect_err("silent");
assert!(
matches!(error, RsyncResolveError::ProbeFailed { reason, .. } if reason == "no version output")
);
}
#[test]
fn cached_resolution_is_stable_within_a_process() {
clear_resolve_cache();
let first = resolve_rsync_cached(None);
let second = resolve_rsync_cached(None);
assert_eq!(first, second);
if let Ok(resolved) = first {
assert!(resolved.path.is_absolute(), "{}", resolved.path.display());
assert!(!resolved.version_line.is_empty());
}
}
}