Skip to main content

rch_common/
rsync_flavor.rs

1//! rsync flavour detection, binary resolution, and argv compatibility
2//! (issue #66).
3//!
4//! rch drives rsync with 3.x-only flags (`--info=progress2`, `--info=stats2`,
5//! `--info=name2`, `--compress-choice=zstd`, `--append-verify`). Not every
6//! `rsync` on PATH accepts them: a stock macOS `/usr/bin/rsync` is
7//! **openrsync** (protocol 29, "rsync 2.6.9 compatible"), older macOS shipped
8//! Apple's real rsync 2.6.9, and OpenBSD ships openrsync in base. Those
9//! binaries reject the flags with `unrecognized option` and exit 1 before a
10//! single byte moves.
11//!
12//! This module does three things:
13//!
14//! 1. [`RsyncFlavor::parse_version_output`] classifies `rsync --version`
15//!    output and [`RsyncCapabilities`] maps the flavour to the concrete argv
16//!    fragments every transfer builder needs (progress, stats, per-file
17//!    listing, compression, resume).
18//! 2. [`resolve_rsync`] picks the binary: an explicit override
19//!    (`RCH_RSYNC_BIN`, then `[transfer] rsync_bin`) wins; otherwise the PATH
20//!    binary is used when it is a modern rsync, and a modern rsync in the
21//!    well-known Homebrew/MacPorts/pkg locations is preferred over a legacy
22//!    PATH binary. Only when nothing modern exists does rch fall back to the
23//!    legacy binary with the compatibility argv.
24//! 3. [`resolve_rsync_cached`] memoizes the resolution per process. A
25//!    `--version` probe costs ~3 ms and the hook is a short-lived process, so
26//!    an on-disk cache would add a staleness surface for no measurable gain.
27//!
28//! The compatibility argv was verified against openrsync on macOS: it accepts
29//! `--progress`, `--stats`, `-vv`, `--out-format=%i %n`, `--itemize-changes`,
30//! `--partial-dir`, `--no-motd`, `--safe-links`, `--prune-empty-dirs`,
31//! `--bwlimit`, `--compress-level`, `--rsync-path`, filter rules, and `-e`.
32//! It rejects every `--info=*` value, `--compress-choice`, `--append-verify`,
33//! `--msgs2stderr`, `--outbuf`, `--mkpath`, and `--chown`.
34
35use 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
44/// Environment variable naming an explicit rsync binary. Takes precedence
45/// over `[transfer] rsync_bin` so a single shell can be pointed at a test
46/// build without editing config.
47pub const RSYNC_BIN_ENV: &str = "RCH_RSYNC_BIN";
48
49/// Well-known locations of a package-manager rsync that may not be on the
50/// PATH the hook inherits (Homebrew on Apple Silicon and Intel, MacPorts,
51/// BSD pkg / older Homebrew, Homebrew's keg path).
52pub 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
60/// Oldest upstream rsync rch will drive at all. Apple shipped 2.6.9 for
61/// years and openrsync declares compatibility with it; anything older lacks
62/// `--out-format`, which the artifact manifest depends on.
63pub const MIN_SUPPORTED_RSYNC: (u32, u32, u32) = (2, 6, 9);
64
65/// Hard cap on the `--version` probe so a wedged binary or a stalled network
66/// filesystem never hangs the hook.
67const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
68
69/// Largest `--compress-level` zlib accepts; a legacy rsync compresses with
70/// zlib because `--compress-choice=zstd` is unavailable.
71const MAX_ZLIB_COMPRESS_LEVEL: u32 = 9;
72
73/// Which rsync implementation a binary is.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum RsyncFlavor {
77    /// Upstream (samba.org) rsync, any version.
78    Rsync { major: u32, minor: u32, patch: u32 },
79    /// openrsync (OpenBSD's BSD-licensed reimplementation; stock macOS 15+).
80    OpenRsync {
81        /// Wire protocol version openrsync announces (`29` on macOS).
82        protocol: Option<u32>,
83    },
84    /// `--version` output rch could not classify.
85    Unknown,
86}
87
88impl RsyncFlavor {
89    /// Classify the output of `rsync --version`.
90    ///
91    /// Recognized shapes (first non-empty line):
92    ///
93    /// ```text
94    /// rsync  version 3.4.1  protocol version 32        (upstream 3.x)
95    /// rsync  version 2.6.9  protocol version 29        (Apple's legacy build)
96    /// openrsync: protocol version 29                   (openrsync)
97    /// ```
98    ///
99    /// openrsync is checked first because its second line reads
100    /// `rsync version 2.6.9 compatible`, which a naive version-line scan would
101    /// misclassify as upstream 2.6.9.
102    #[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    /// Whether this flavour understands rsync 3.1+'s `--info=` family, i.e.
132    /// rch's preferred argv works unmodified.
133    #[must_use]
134    pub const fn supports_info_flags(self) -> bool {
135        self.capabilities().info_flags
136    }
137
138    /// Whether rch can drive this binary at all (natively or through the
139    /// compatibility argv). `Unknown` is assumed drivable: refusing would turn
140    /// an unrecognized `--version` banner into a hard outage, and the doctor
141    /// surfaces the uncertainty separately.
142    #[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    /// Argv capabilities of this flavour.
164    ///
165    /// `Unknown` maps to the modern set: that is exactly what rch did before
166    /// flavour detection existed, so an unclassifiable banner never changes
167    /// behaviour for a working setup.
168    #[must_use]
169    pub const fn capabilities(self) -> RsyncCapabilities {
170        match self {
171            Self::Rsync { major, minor, .. } => RsyncCapabilities {
172                // --info= arrived in 3.1.0 (with the stats2 / name2 / progress2
173                // values and the `(reg: N, dir: M)` stats breakdown).
174                info_flags: version_at_least(major, minor, 3, 1),
175                stats_regular_file_breakdown: version_at_least(major, minor, 3, 1),
176                // --compress-choice / zstd arrived in 3.2.0.
177                compress_choice: version_at_least(major, minor, 3, 2),
178                // --append-verify and --no-motd arrived in 3.0.0.
179                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    // Accept `3.4.1`, `3.4.1-dev`, `3.2.7pre1`, `2.6.9`: digits and dots up to
217    // the first character that is neither.
218    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/// What a given rsync binary accepts. Every transfer builder asks this
230/// struct for its argv fragments instead of hard-coding rsync 3.x flags.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
232pub struct RsyncCapabilities {
233    /// `--info=progress2` / `--info=stats2` / `--info=name2` (rsync >= 3.1).
234    pub info_flags: bool,
235    /// `--stats` prints `Number of files: N (reg: R, dir: D)` (rsync >= 3.1),
236    /// which the zero-build-output detector uses as its completeness
237    /// cross-check. Without it the detector fails open.
238    pub stats_regular_file_breakdown: bool,
239    /// `--compress-choice=zstd` (rsync >= 3.2). Without it `-z` means zlib and
240    /// `--compress-level` is capped at 9.
241    pub compress_choice: bool,
242    /// `--append-verify` (rsync >= 3.0). Without it resumable uploads rely on
243    /// `--partial` plus the delta algorithm alone.
244    pub append_verify: bool,
245    /// `--no-motd` (rsync >= 3.0, openrsync).
246    pub no_motd: bool,
247}
248
249impl RsyncCapabilities {
250    /// Everything rch's preferred argv needs (rsync >= 3.2).
251    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    /// The openrsync / rsync 2.6.9 compatibility set.
260    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    /// Whether the compatibility argv (rather than the preferred one) is in
269    /// use.
270    #[must_use]
271    pub const fn is_compatibility_mode(&self) -> bool {
272        !self.info_flags
273    }
274
275    /// Progress reporting that refreshes often enough to feed the silence
276    /// stall detector. `--info=progress2` prints one cumulative line refreshed
277    /// with bare `\r`; legacy `--progress` prints a per-file line in the same
278    /// `<bytes> <pct>% <rate> <eta> (xfer#N, to-check=A/B)` shape.
279    #[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    /// Transfer statistics block (`Number of files transferred:`, `sent N
289    /// bytes`). Both spellings print the lines `parse_rsync_bytes` /
290    /// `parse_rsync_files` read.
291    #[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    /// Make rsync itemize EVERY matched regular file, including up-to-date
301    /// ones (`.f` lines), when combined with `--out-format='%i %n'`.
302    /// `--info=name2` is the modern spelling; `-vv` is the pre-3.1 one and is
303    /// what openrsync honours (verified: `-v` alone lists only transferred
304    /// files, `-vv` also lists the `.f` matches).
305    #[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    /// Compression flags for a requested zstd level (`0` disables).
315    ///
316    /// Modern rsync gets `--compress-choice=zstd --compress-level=N`. A legacy
317    /// binary compresses with zlib via the `-z` every builder already passes,
318    /// so only `--compress-level` is emitted, clamped to zlib's maximum of 9.
319    #[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    /// Resumable-upload flags for an immutable file: `--partial` always,
338    /// plus `--append-verify` where supported so a retry extends the remote
339    /// partial in place instead of re-deltaing it.
340    #[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    /// `--no-motd` where supported (suppresses daemon-mode banners that would
350    /// pollute parsed stdout); nothing otherwise.
351    #[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/// How the resolved binary was chosen.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum RsyncSource {
361    /// `RCH_RSYNC_BIN` in the environment.
362    Env,
363    /// `[transfer] rsync_bin` in config.
364    Config,
365    /// First `rsync` on PATH.
366    Path,
367    /// A modern rsync in [`PREFERRED_RSYNC_LOCATIONS`], chosen over a legacy
368    /// PATH binary.
369    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/// The rsync binary rch will exec, with its probed flavour.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct ResolvedRsync {
386    /// Absolute path handed to `Command::new`.
387    pub path: PathBuf,
388    /// Probed flavour.
389    pub flavor: RsyncFlavor,
390    /// First line of `--version` output, for diagnostics.
391    pub version_line: String,
392    /// How the binary was chosen.
393    pub source: RsyncSource,
394    /// A legacy PATH binary that was skipped in favour of `path`, if any —
395    /// surfaced by the doctor so the operator understands why `which rsync`
396    /// and rch disagree.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub shadowed: Option<ShadowedRsync>,
399}
400
401/// The PATH rsync that lost to a preferred-location binary.
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct ShadowedRsync {
404    pub path: PathBuf,
405    pub flavor: RsyncFlavor,
406}
407
408impl ResolvedRsync {
409    /// Argv capabilities of the resolved binary.
410    #[must_use]
411    pub const fn capabilities(&self) -> RsyncCapabilities {
412        self.flavor.capabilities()
413    }
414
415    /// One-line human summary, e.g.
416    /// `rsync 3.4.1 at /opt/homebrew/bin/rsync (well-known install location; PATH rsync is openrsync (protocol 29) at /usr/bin/rsync)`.
417    #[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/// Why no usable rsync could be resolved.
437#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
438pub enum RsyncResolveError {
439    /// Nothing named `rsync` on PATH or in the well-known locations.
440    #[error("rsync not found on PATH or in {}", PREFERRED_RSYNC_LOCATIONS.join(", "))]
441    NotFound,
442    /// An explicit override names a binary that does not exist or is not
443    /// executable.
444    #[error("{origin} points at {path}, which is not an executable file")]
445    OverrideMissing { origin: RsyncSource, path: PathBuf },
446    /// The binary exists but `--version` failed, hung, or printed nothing.
447    #[error("{path} --version failed: {reason}")]
448    ProbeFailed { path: PathBuf, reason: String },
449}
450
451/// Probe `<path> --version` with a hard timeout and return the first
452/// non-empty output line (stdout preferred, stderr as fallback).
453pub 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
518/// Locate `name` on PATH (first executable hit), mirroring `which`.
519fn 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
532/// Turn an override value into a concrete path: tilde-expand it, and look a
533/// bare name (no separator) up on PATH.
534fn 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
547/// Resolve the rsync binary rch should exec.
548///
549/// `configured` is `[transfer] rsync_bin`. Precedence: `RCH_RSYNC_BIN`, then
550/// `configured`, then PATH (kept when modern), then the first modern binary in
551/// [`PREFERRED_RSYNC_LOCATIONS`], then the legacy PATH binary. An explicit
552/// override is honoured whatever its flavour — the operator asked for it —
553/// but must exist and answer `--version`.
554pub 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    // A modern PATH binary is the common case and needs no further probing.
594    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    // Legacy or absent PATH binary: prefer a modern one from a well-known
608    // package-manager location.
609    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    // Nothing modern anywhere: drive the PATH binary with the compatibility
643    // argv, or report that there is no rsync at all.
644    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
671/// [`resolve_rsync`] memoized per process, keyed by the configured override.
672///
673/// Errors are cached too: a missing binary does not appear between two
674/// transfers of the same job, and re-probing on every builder call would
675/// only repeat the same failed spawn.
676pub 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
690/// Drop the per-process resolution cache (tests, or after `rch config`
691/// rewrites `rsync_bin` inside a long-lived process).
692pub 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    /// Captured from Homebrew rsync 3.4.1 on macOS.
703    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    /// Captured from Debian bookworm's rsync 3.2.7.
709    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    /// Captured from stock macOS `/usr/bin/rsync` (Sequoia and later).
713    const OPENRSYNC_MACOS: &str =
714        "openrsync: protocol version 29\nrsync version 2.6.9 compatible\n";
715
716    /// Apple's legacy rsync build (macOS up to Sonoma).
717    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        // zlib tops out at 9; a zstd-tuned level must not be passed through.
870        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}