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 escapes_remote_args(self) -> bool {
match self {
Self::Rsync {
major,
minor,
patch,
} => major > 3 || (major == 3 && (minor > 2 || (minor == 2 && patch >= 4))),
Self::OpenRsync { .. } => false,
Self::Unknown => true,
}
}
#[must_use]
pub fn remote_path_arg(self, path: &str) -> String {
if !self.escapes_remote_args() {
return shell_escape::escape(std::borrow::Cow::Borrowed(path)).into_owned();
}
if self.needs_literal_remote_path(path) {
return if path.ends_with('/') { "./" } else { "." }.to_string();
}
path.to_string()
}
fn needs_literal_remote_path(self, path: &str) -> bool {
self.escapes_remote_args() && path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
}
#[must_use]
pub fn remote_path_command(self, command: String, path: &str) -> String {
if !self.needs_literal_remote_path(path) {
return command;
}
let literal = shell_escape::escape(std::borrow::Cow::Borrowed(path));
let script = format!(
"rch_argc=$#\n\
[ \"$rch_argc\" -gt 0 ] || exit 2\n\
while [ \"$rch_argc\" -gt 0 ]; do\n\
rch_arg=$1; shift; rch_argc=$((rch_argc - 1))\n\
if [ \"$rch_argc\" -eq 0 ]; then\n\
case \"$rch_arg\" in .|./) ;; *) exit 2 ;; esac\n\
set -- \"$@\" {literal}\n\
else\n\
set -- \"$@\" \"$rch_arg\"\n\
fi\n\
done\n\
{command} \"$@\""
);
format!(
"sh -c {} rch-rsync-literal-path",
shell_escape::escape(std::borrow::Cow::Owned(script))
)
}
#[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,
}
}
}
pub fn configure_rsync_remote_args(command: &mut Command) {
command
.env("RSYNC_OLD_ARGS", "0")
.env("RSYNC_PROTECT_ARGS", "0");
}
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::*;
#[test]
fn remote_path_arg_version_boundary_and_plain_path_control() {
let versions = [
((2, 6, 9), false),
((3, 0, 9), false),
((3, 1, 3), false),
((3, 2, 3), false),
((3, 2, 4), true),
((3, 2, 7), true),
((3, 4, 1), true),
((4, 0, 0), true),
];
for ((major, minor, patch), modern) in versions {
let flavor = RsyncFlavor::Rsync {
major,
minor,
patch,
};
assert_eq!(flavor.escapes_remote_args(), modern, "{flavor}");
}
assert!(!RsyncFlavor::OpenRsync { protocol: Some(29) }.escapes_remote_args());
assert!(RsyncFlavor::Unknown.escapes_remote_args());
for (flavor, modern) in [
(RsyncFlavor::OpenRsync { protocol: Some(29) }, false),
(RsyncFlavor::Unknown, true),
] {
assert_eq!(flavor.escapes_remote_args(), modern, "{flavor}");
assert_eq!(
flavor.remote_path_arg("/data/projects/plain/"),
"/data/projects/plain/"
);
for path in ["/data/projects/p q/", "/data/projects/x:y/"] {
let expected = if modern {
path.to_string()
} else {
format!("'{path}'")
};
assert_eq!(flavor.remote_path_arg(path), expected, "{flavor}");
}
}
}
#[test]
fn remote_path_arg_preserves_literal_metacharacters() {
let flavor = RsyncFlavor::Rsync {
major: 3,
minor: 2,
patch: 4,
};
for (path, expected) in [
("/data/p'q:x/", "/data/p'q:x/"),
(
"/data/$(touch marker);$HOME/",
"/data/$(touch marker);$HOME/",
),
(r"/data/a\b/", r"/data/a\b/"),
("/data/a[1]/", "./"),
("/data/a*?/", "./"),
(r"/data/a\[1]/", "./"),
("/data/a*/archive.tar", "."),
("/data/p q/out:x/", "/data/p q/out:x/"),
] {
assert_eq!(flavor.remote_path_arg(path), expected);
}
}
#[test]
fn remote_argument_mode_overrides_are_child_local() {
use std::ffi::OsStr;
let mut command = Command::new("rsync");
command
.env("RSYNC_OLD_ARGS", "2")
.env("RSYNC_PROTECT_ARGS", "1");
configure_rsync_remote_args(&mut command);
for key in ["RSYNC_OLD_ARGS", "RSYNC_PROTECT_ARGS"] {
assert_eq!(
command.get_envs().find(|(name, _)| *name == key),
Some((OsStr::new(key), Some(OsStr::new("0"))))
);
}
}
#[cfg(unix)]
#[test]
fn remote_path_arg_real_rsync_round_trip() {
use std::os::unix::fs::{PermissionsExt, symlink};
let resolved = match resolve_rsync_cached(None) {
Ok(resolved) => resolved,
Err(RsyncResolveError::NotFound) => {
eprintln!("skipping real rsync regression: rsync is not installed");
return;
}
Err(error) => panic!("cannot resolve test rsync: {error}"),
};
let temp = tempfile::tempdir().expect("fixture directory");
let rsh = temp.path().join("rsh");
std::fs::write(&rsh, "#!/bin/sh\nshift\nexec /bin/sh -c \"$*\"\n")
.expect("write remote shell");
std::fs::set_permissions(&rsh, std::fs::Permissions::from_mode(0o700))
.expect("executable remote shell");
let run = |flags: &[&str], source: &str, destination: &str, remote: &str| {
let mut command = Command::new(&resolved.path);
command
.env("RSYNC_OLD_ARGS", "1")
.env("RSYNC_PROTECT_ARGS", "1");
configure_rsync_remote_args(&mut command);
let output = command
.env("LC_ALL", "C")
.args(flags)
.arg("-e")
.arg(shell_escape::escape(rsh.to_string_lossy()).as_ref())
.arg("--rsync-path")
.arg(resolved.flavor.remote_path_command(
shell_escape::escape(resolved.path.to_string_lossy()).into_owned(),
remote,
))
.arg(source)
.arg(destination)
.output()
.expect("run rsync");
assert!(
output.status.success(),
"rsync {flags:?} {source:?} {destination:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
output
};
let worker = temp.path().join("worker");
std::fs::create_dir_all(worker.join("a1")).expect("shadow directory");
std::fs::write(worker.join("a1/payload"), "wrong sibling").expect("shadow file");
let secret = temp.path().join("outside-secret");
std::fs::write(&secret, "must not be retrieved").expect("outside file");
for name in ["plain", "p q", "x:y", "a[1]", "a*", r"back\*"] {
let source = temp.path().join("source").join(name);
let remote = worker.join(name);
let local = temp.path().join("retrieved").join(name);
std::fs::create_dir_all(&source).expect("source directory");
std::fs::create_dir_all(&local).expect("retrieval directory");
std::fs::write(source.join("payload"), name).expect("source payload");
let remote_raw = format!("{}/", remote.display());
let remote_operand =
format!("loopback:{}", resolved.flavor.remote_path_arg(&remote_raw));
let source_operand = format!("{}/", source.display());
run(&["-a"], &source_operand, &remote_operand, &remote_raw);
assert_eq!(
std::fs::read(remote.join("payload")).unwrap(),
name.as_bytes()
);
let barrier = run(
&["-a", "--checksum", "--dry-run", "--itemize-changes"],
&source_operand,
&remote_operand,
&remote_raw,
);
assert!(barrier.stdout.is_empty(), "no-delta barrier: {name}");
symlink(&secret, remote.join("escape-link")).expect("unsafe symlink fixture");
run(
&["-a", "--safe-links"],
&remote_operand,
&format!("{}/", local.display()),
&remote_raw,
);
assert_eq!(
std::fs::read(local.join("payload")).unwrap(),
name.as_bytes()
);
assert!(std::fs::symlink_metadata(local.join("escape-link")).is_err());
let result = remote.join("out p:q");
std::fs::create_dir_all(&result).expect("result directory");
std::fs::write(result.join("result"), name).expect("result payload");
run(
&["-a", "--safe-links"],
&format!(
"loopback:{}",
resolved
.flavor
.remote_path_arg(&format!("{}/", result.display()))
),
&format!("{}/out p:q/", local.display()),
&format!("{}/", result.display()),
);
assert_eq!(
std::fs::read(local.join("out p:q/result")).unwrap(),
name.as_bytes()
);
let archive_raw = format!("{}/archive.tar", remote.display());
run(
&["-a"],
source.join("payload").to_str().unwrap(),
&format!("loopback:{}", resolved.flavor.remote_path_arg(&archive_raw)),
&archive_raw,
);
assert_eq!(
std::fs::read(remote.join("archive.tar")).unwrap(),
name.as_bytes()
);
}
}
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());
}
}
}