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 the client backslash-escapes shell-active remote arguments.
132    /// This changed in 3.2.4, independently of the 3.1 `--info` flags.
133    /// As with the other capabilities, an unknown banner assumes modern rsync.
134    #[must_use]
135    pub const fn escapes_remote_args(self) -> bool {
136        match self {
137            Self::Rsync {
138                major,
139                minor,
140                patch,
141            } => major > 3 || (major == 3 && (minor > 2 || (minor == 2 && patch >= 4))),
142            Self::OpenRsync { .. } => false,
143            Self::Unknown => true,
144        }
145    }
146
147    /// Encode a validated, literal remote filename for an rsync argv operand.
148    ///
149    /// This is NOT shell-command quoting. With rsync >= 3.2.4, shell quotes
150    /// become filename bytes (issue #91). Pass spaces, colons and quotes through
151    /// for rsync to protect. Literal wildcard paths instead use an inert operand
152    /// paired with [`Self::remote_path_command`]; neither the remote shell nor
153    /// rsync's wildcard/backslash argument handling may select a sibling path.
154    /// Legacy rsync/openrsync still need shell quoting.
155    ///
156    /// Encode the whole path once, after joining any child path and trailing
157    /// slash. Keep the caller's path validation and use shell escaping separately
158    /// for commands such as `--rsync-path='mkdir ... && rsync'`. Commands using
159    /// this encoder must also use [`Self::remote_path_command`] and
160    /// [`configure_rsync_remote_args`].
161    #[must_use]
162    pub fn remote_path_arg(self, path: &str) -> String {
163        if !self.escapes_remote_args() {
164            return shell_escape::escape(std::borrow::Cow::Borrowed(path)).into_owned();
165        }
166        if self.needs_literal_remote_path(path) {
167            return if path.ends_with('/') { "./" } else { "." }.to_string();
168        }
169        path.to_string()
170    }
171
172    fn needs_literal_remote_path(self, path: &str) -> bool {
173        self.escapes_remote_args() && path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
174    }
175
176    /// Pair an rsync server command with a literal remote filename operand.
177    /// Ordinary paths need no wrapper. For wildcard paths, replace the inert
178    /// final operand with the quoted literal immediately before server execution.
179    /// This avoids both shell globbing and upstream rsync's inconsistent handling
180    /// of backslash-escaped wildcard operands. No `eval`, secluded/old-args mode,
181    /// remote-version requirement, or change of remote working directory is used.
182    /// All preceding server arguments are retained in their original order.
183    #[must_use]
184    pub fn remote_path_command(self, command: String, path: &str) -> String {
185        if !self.needs_literal_remote_path(path) {
186            return command;
187        }
188        let literal = shell_escape::escape(std::borrow::Cow::Borrowed(path));
189        let script = format!(
190            "rch_argc=$#\n\
191             [ \"$rch_argc\" -gt 0 ] || exit 2\n\
192             while [ \"$rch_argc\" -gt 0 ]; do\n\
193               rch_arg=$1; shift; rch_argc=$((rch_argc - 1))\n\
194               if [ \"$rch_argc\" -eq 0 ]; then\n\
195                 case \"$rch_arg\" in .|./) ;; *) exit 2 ;; esac\n\
196                 set -- \"$@\" {literal}\n\
197               else\n\
198                 set -- \"$@\" \"$rch_arg\"\n\
199               fi\n\
200             done\n\
201             {command} \"$@\""
202        );
203        format!(
204            "sh -c {} rch-rsync-literal-path",
205            shell_escape::escape(std::borrow::Cow::Owned(script))
206        )
207    }
208
209    /// Whether this flavour understands rsync 3.1+'s `--info=` family, i.e.
210    /// rch's preferred argv works unmodified.
211    #[must_use]
212    pub const fn supports_info_flags(self) -> bool {
213        self.capabilities().info_flags
214    }
215
216    /// Whether rch can drive this binary at all (natively or through the
217    /// compatibility argv). `Unknown` is assumed drivable: refusing would turn
218    /// an unrecognized `--version` banner into a hard outage, and the doctor
219    /// surfaces the uncertainty separately.
220    #[must_use]
221    pub const fn is_supported(self) -> bool {
222        match self {
223            Self::Rsync {
224                major,
225                minor,
226                patch,
227            } => {
228                let (min_major, min_minor, min_patch) = MIN_SUPPORTED_RSYNC;
229                if major != min_major {
230                    major > min_major
231                } else if minor != min_minor {
232                    minor > min_minor
233                } else {
234                    patch >= min_patch
235                }
236            }
237            Self::OpenRsync { .. } | Self::Unknown => true,
238        }
239    }
240
241    /// Argv capabilities of this flavour.
242    ///
243    /// `Unknown` maps to the modern set: that is exactly what rch did before
244    /// flavour detection existed, so an unclassifiable banner never changes
245    /// behaviour for a working setup.
246    #[must_use]
247    pub const fn capabilities(self) -> RsyncCapabilities {
248        match self {
249            Self::Rsync { major, minor, .. } => RsyncCapabilities {
250                // --info= arrived in 3.1.0 (with the stats2 / name2 / progress2
251                // values and the `(reg: N, dir: M)` stats breakdown).
252                info_flags: version_at_least(major, minor, 3, 1),
253                stats_regular_file_breakdown: version_at_least(major, minor, 3, 1),
254                // --compress-choice / zstd arrived in 3.2.0.
255                compress_choice: version_at_least(major, minor, 3, 2),
256                // --append-verify and --no-motd arrived in 3.0.0.
257                append_verify: version_at_least(major, minor, 3, 0),
258                no_motd: version_at_least(major, minor, 3, 0),
259            },
260            Self::OpenRsync { .. } => RsyncCapabilities {
261                info_flags: false,
262                stats_regular_file_breakdown: false,
263                compress_choice: false,
264                append_verify: false,
265                no_motd: true,
266            },
267            Self::Unknown => RsyncCapabilities::MODERN,
268        }
269    }
270}
271
272/// Pin argument handling for this child, without changing the process environment.
273/// Inherited workaround/secluded-args settings must not change how operands from
274/// [`RsyncFlavor::remote_path_arg`] are interpreted. In particular, never enable
275/// `--old-args`, which also disables rsync's extra file-list safety checks.
276pub fn configure_rsync_remote_args(command: &mut Command) {
277    command
278        .env("RSYNC_OLD_ARGS", "0")
279        .env("RSYNC_PROTECT_ARGS", "0");
280}
281
282impl fmt::Display for RsyncFlavor {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::Rsync {
286                major,
287                minor,
288                patch,
289            } => write!(f, "rsync {major}.{minor}.{patch}"),
290            Self::OpenRsync {
291                protocol: Some(protocol),
292            } => write!(f, "openrsync (protocol {protocol})"),
293            Self::OpenRsync { protocol: None } => f.write_str("openrsync"),
294            Self::Unknown => f.write_str("unrecognized rsync"),
295        }
296    }
297}
298
299const fn version_at_least(major: u32, minor: u32, want_major: u32, want_minor: u32) -> bool {
300    major > want_major || (major == want_major && minor >= want_minor)
301}
302
303fn parse_semver_prefix(token: &str) -> Option<(u32, u32, u32)> {
304    // Accept `3.4.1`, `3.4.1-dev`, `3.2.7pre1`, `2.6.9`: digits and dots up to
305    // the first character that is neither.
306    let numeric: String = token
307        .chars()
308        .take_while(|ch| ch.is_ascii_digit() || *ch == '.')
309        .collect();
310    let mut parts = numeric.split('.').filter(|part| !part.is_empty());
311    let major = parts.next()?.parse().ok()?;
312    let minor = parts.next().map_or(Some(0), |part| part.parse().ok())?;
313    let patch = parts.next().map_or(Some(0), |part| part.parse().ok())?;
314    Some((major, minor, patch))
315}
316
317/// What a given rsync binary accepts. Every transfer builder asks this
318/// struct for its argv fragments instead of hard-coding rsync 3.x flags.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
320pub struct RsyncCapabilities {
321    /// `--info=progress2` / `--info=stats2` / `--info=name2` (rsync >= 3.1).
322    pub info_flags: bool,
323    /// `--stats` prints `Number of files: N (reg: R, dir: D)` (rsync >= 3.1),
324    /// which the zero-build-output detector uses as its completeness
325    /// cross-check. Without it the detector fails open.
326    pub stats_regular_file_breakdown: bool,
327    /// `--compress-choice=zstd` (rsync >= 3.2). Without it `-z` means zlib and
328    /// `--compress-level` is capped at 9.
329    pub compress_choice: bool,
330    /// `--append-verify` (rsync >= 3.0). Without it resumable uploads rely on
331    /// `--partial` plus the delta algorithm alone.
332    pub append_verify: bool,
333    /// `--no-motd` (rsync >= 3.0, openrsync).
334    pub no_motd: bool,
335}
336
337impl RsyncCapabilities {
338    /// Everything rch's preferred argv needs (rsync >= 3.2).
339    pub const MODERN: Self = Self {
340        info_flags: true,
341        stats_regular_file_breakdown: true,
342        compress_choice: true,
343        append_verify: true,
344        no_motd: true,
345    };
346
347    /// The openrsync / rsync 2.6.9 compatibility set.
348    pub const LEGACY: Self = Self {
349        info_flags: false,
350        stats_regular_file_breakdown: false,
351        compress_choice: false,
352        append_verify: false,
353        no_motd: false,
354    };
355
356    /// Whether the compatibility argv (rather than the preferred one) is in
357    /// use.
358    #[must_use]
359    pub const fn is_compatibility_mode(&self) -> bool {
360        !self.info_flags
361    }
362
363    /// Progress reporting that refreshes often enough to feed the silence
364    /// stall detector. `--info=progress2` prints one cumulative line refreshed
365    /// with bare `\r`; legacy `--progress` prints a per-file line in the same
366    /// `<bytes> <pct>% <rate> <eta> (xfer#N, to-check=A/B)` shape.
367    #[must_use]
368    pub const fn progress_args(&self) -> &'static [&'static str] {
369        if self.info_flags {
370            &["--info=progress2"]
371        } else {
372            &["--progress"]
373        }
374    }
375
376    /// Transfer statistics block (`Number of files transferred:`, `sent N
377    /// bytes`). Both spellings print the lines `parse_rsync_bytes` /
378    /// `parse_rsync_files` read.
379    #[must_use]
380    pub const fn stats_args(&self) -> &'static [&'static str] {
381        if self.info_flags {
382            &["--info=stats2"]
383        } else {
384            &["--stats"]
385        }
386    }
387
388    /// Make rsync itemize EVERY matched regular file, including up-to-date
389    /// ones (`.f` lines), when combined with `--out-format='%i %n'`.
390    /// `--info=name2` is the modern spelling; `-vv` is the pre-3.1 one and is
391    /// what openrsync honours (verified: `-v` alone lists only transferred
392    /// files, `-vv` also lists the `.f` matches).
393    #[must_use]
394    pub const fn name_listing_args(&self) -> &'static [&'static str] {
395        if self.info_flags {
396            &["--info=name2"]
397        } else {
398            &["-vv"]
399        }
400    }
401
402    /// Compression flags for a requested zstd level (`0` disables).
403    ///
404    /// Modern rsync gets `--compress-choice=zstd --compress-level=N`. A legacy
405    /// binary compresses with zlib via the `-z` every builder already passes,
406    /// so only `--compress-level` is emitted, clamped to zlib's maximum of 9.
407    #[must_use]
408    pub fn compression_args(&self, level: u32) -> Vec<String> {
409        if level == 0 {
410            return Vec::new();
411        }
412        if self.compress_choice {
413            vec![
414                "--compress-choice=zstd".to_string(),
415                format!("--compress-level={level}"),
416            ]
417        } else {
418            vec![format!(
419                "--compress-level={}",
420                level.min(MAX_ZLIB_COMPRESS_LEVEL)
421            )]
422        }
423    }
424
425    /// Resumable-upload flags for an immutable file: `--partial` always,
426    /// plus `--append-verify` where supported so a retry extends the remote
427    /// partial in place instead of re-deltaing it.
428    #[must_use]
429    pub const fn resume_args(&self) -> &'static [&'static str] {
430        if self.append_verify {
431            &["--partial", "--append-verify"]
432        } else {
433            &["--partial"]
434        }
435    }
436
437    /// `--no-motd` where supported (suppresses daemon-mode banners that would
438    /// pollute parsed stdout); nothing otherwise.
439    #[must_use]
440    pub const fn no_motd_args(&self) -> &'static [&'static str] {
441        if self.no_motd { &["--no-motd"] } else { &[] }
442    }
443}
444
445/// How the resolved binary was chosen.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum RsyncSource {
449    /// `RCH_RSYNC_BIN` in the environment.
450    Env,
451    /// `[transfer] rsync_bin` in config.
452    Config,
453    /// First `rsync` on PATH.
454    Path,
455    /// A modern rsync in [`PREFERRED_RSYNC_LOCATIONS`], chosen over a legacy
456    /// PATH binary.
457    PreferredLocation,
458}
459
460impl fmt::Display for RsyncSource {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        f.write_str(match self {
463            Self::Env => RSYNC_BIN_ENV,
464            Self::Config => "[transfer] rsync_bin",
465            Self::Path => "PATH",
466            Self::PreferredLocation => "well-known install location",
467        })
468    }
469}
470
471/// The rsync binary rch will exec, with its probed flavour.
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct ResolvedRsync {
474    /// Absolute path handed to `Command::new`.
475    pub path: PathBuf,
476    /// Probed flavour.
477    pub flavor: RsyncFlavor,
478    /// First line of `--version` output, for diagnostics.
479    pub version_line: String,
480    /// How the binary was chosen.
481    pub source: RsyncSource,
482    /// A legacy PATH binary that was skipped in favour of `path`, if any —
483    /// surfaced by the doctor so the operator understands why `which rsync`
484    /// and rch disagree.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub shadowed: Option<ShadowedRsync>,
487}
488
489/// The PATH rsync that lost to a preferred-location binary.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct ShadowedRsync {
492    pub path: PathBuf,
493    pub flavor: RsyncFlavor,
494}
495
496impl ResolvedRsync {
497    /// Argv capabilities of the resolved binary.
498    #[must_use]
499    pub const fn capabilities(&self) -> RsyncCapabilities {
500        self.flavor.capabilities()
501    }
502
503    /// One-line human summary, e.g.
504    /// `rsync 3.4.1 at /opt/homebrew/bin/rsync (well-known install location; PATH rsync is openrsync (protocol 29) at /usr/bin/rsync)`.
505    #[must_use]
506    pub fn describe(&self) -> String {
507        let mut text = format!(
508            "{} at {} ({})",
509            self.flavor,
510            self.path.display(),
511            self.source
512        );
513        if let Some(shadowed) = &self.shadowed {
514            text.push_str(&format!(
515                "; PATH rsync is {} at {}",
516                shadowed.flavor,
517                shadowed.path.display()
518            ));
519        }
520        text
521    }
522}
523
524/// Why no usable rsync could be resolved.
525#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
526pub enum RsyncResolveError {
527    /// Nothing named `rsync` on PATH or in the well-known locations.
528    #[error("rsync not found on PATH or in {}", PREFERRED_RSYNC_LOCATIONS.join(", "))]
529    NotFound,
530    /// An explicit override names a binary that does not exist or is not
531    /// executable.
532    #[error("{origin} points at {path}, which is not an executable file")]
533    OverrideMissing { origin: RsyncSource, path: PathBuf },
534    /// The binary exists but `--version` failed, hung, or printed nothing.
535    #[error("{path} --version failed: {reason}")]
536    ProbeFailed { path: PathBuf, reason: String },
537}
538
539/// Probe `<path> --version` with a hard timeout and return the first
540/// non-empty output line (stdout preferred, stderr as fallback).
541pub fn probe_rsync_version(path: &Path) -> Result<String, RsyncResolveError> {
542    let fail = |reason: String| RsyncResolveError::ProbeFailed {
543        path: path.to_path_buf(),
544        reason,
545    };
546    let mut child = Command::new(path)
547        .arg("--version")
548        .env("LC_ALL", "C")
549        .stdin(Stdio::null())
550        .stdout(Stdio::piped())
551        .stderr(Stdio::piped())
552        .spawn()
553        .map_err(|error| fail(error.to_string()))?;
554
555    let started = Instant::now();
556    loop {
557        match child.try_wait() {
558            Ok(Some(_)) => break,
559            Ok(None) if started.elapsed() >= VERSION_PROBE_TIMEOUT => {
560                let _ = child.kill();
561                let _ = child.wait();
562                return Err(fail(format!(
563                    "timed out after {}s",
564                    VERSION_PROBE_TIMEOUT.as_secs()
565                )));
566            }
567            Ok(None) => std::thread::sleep(Duration::from_millis(10)),
568            Err(error) => return Err(fail(error.to_string())),
569        }
570    }
571    let output = child
572        .wait_with_output()
573        .map_err(|error| fail(error.to_string()))?;
574    let stdout = String::from_utf8_lossy(&output.stdout);
575    let stderr = String::from_utf8_lossy(&output.stderr);
576    let text = if stdout.trim().is_empty() {
577        stderr.as_ref()
578    } else {
579        stdout.as_ref()
580    };
581    text.lines()
582        .map(str::trim)
583        .find(|line| !line.is_empty())
584        .map(str::to_string)
585        .ok_or_else(|| fail("no version output".to_string()))
586}
587
588fn is_executable_file(path: &Path) -> bool {
589    let Ok(metadata) = std::fs::metadata(path) else {
590        return false;
591    };
592    if !metadata.is_file() {
593        return false;
594    }
595    #[cfg(unix)]
596    {
597        use std::os::unix::fs::PermissionsExt;
598        metadata.permissions().mode() & 0o111 != 0
599    }
600    #[cfg(not(unix))]
601    {
602        true
603    }
604}
605
606/// Locate `name` on PATH (first executable hit), mirroring `which`.
607fn find_in_path(name: &str) -> Option<PathBuf> {
608    let path_var = std::env::var_os("PATH")?;
609    let names: Vec<String> = if cfg!(windows) {
610        vec![format!("{name}.exe"), name.to_string()]
611    } else {
612        vec![name.to_string()]
613    };
614    std::env::split_paths(&path_var)
615        .filter(|dir| !dir.as_os_str().is_empty())
616        .flat_map(|dir| names.iter().map(move |n| dir.join(n)))
617        .find(|candidate| is_executable_file(candidate))
618}
619
620/// Turn an override value into a concrete path: tilde-expand it, and look a
621/// bare name (no separator) up on PATH.
622fn override_path(value: &str) -> Option<PathBuf> {
623    let expanded = shellexpand::tilde(value.trim()).into_owned();
624    if expanded.is_empty() {
625        return None;
626    }
627    let path = PathBuf::from(&expanded);
628    if path.components().count() > 1 || path.is_absolute() {
629        Some(path)
630    } else {
631        find_in_path(&expanded).or(Some(path))
632    }
633}
634
635/// Resolve the rsync binary rch should exec.
636///
637/// `configured` is `[transfer] rsync_bin`. Precedence: `RCH_RSYNC_BIN`, then
638/// `configured`, then PATH (kept when modern), then the first modern binary in
639/// [`PREFERRED_RSYNC_LOCATIONS`], then the legacy PATH binary. An explicit
640/// override is honoured whatever its flavour — the operator asked for it —
641/// but must exist and answer `--version`.
642pub fn resolve_rsync(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
643    let env_override = std::env::var(RSYNC_BIN_ENV)
644        .ok()
645        .filter(|value| !value.trim().is_empty());
646    let explicit = env_override
647        .as_deref()
648        .map(|value| (value, RsyncSource::Env))
649        .or_else(|| {
650            configured
651                .filter(|value| !value.trim().is_empty())
652                .map(|value| (value, RsyncSource::Config))
653        });
654
655    if let Some((value, source)) = explicit {
656        let path = override_path(value).ok_or_else(|| RsyncResolveError::OverrideMissing {
657            origin: source,
658            path: PathBuf::from(value),
659        })?;
660        if !is_executable_file(&path) {
661            return Err(RsyncResolveError::OverrideMissing {
662                origin: source,
663                path,
664            });
665        }
666        let version_line = probe_rsync_version(&path)?;
667        return Ok(ResolvedRsync {
668            flavor: RsyncFlavor::parse_version_output(&version_line),
669            path,
670            version_line,
671            source,
672            shadowed: None,
673        });
674    }
675
676    let path_candidate = find_in_path("rsync").map(|path| {
677        let probed = probe_rsync_version(&path);
678        (path, probed)
679    });
680
681    // A modern PATH binary is the common case and needs no further probing.
682    if let Some((path, Ok(version_line))) = &path_candidate {
683        let flavor = RsyncFlavor::parse_version_output(version_line);
684        if flavor.supports_info_flags() {
685            return Ok(ResolvedRsync {
686                path: path.clone(),
687                flavor,
688                version_line: version_line.clone(),
689                source: RsyncSource::Path,
690                shadowed: None,
691            });
692        }
693    }
694
695    // Legacy or absent PATH binary: prefer a modern one from a well-known
696    // package-manager location.
697    for location in PREFERRED_RSYNC_LOCATIONS {
698        let path = PathBuf::from(location);
699        if path_candidate
700            .as_ref()
701            .is_some_and(|(path_path, _)| same_file(path_path, &path))
702            || !is_executable_file(&path)
703        {
704            continue;
705        }
706        let Ok(version_line) = probe_rsync_version(&path) else {
707            continue;
708        };
709        let flavor = RsyncFlavor::parse_version_output(&version_line);
710        if !flavor.supports_info_flags() {
711            continue;
712        }
713        let shadowed = path_candidate
714            .as_ref()
715            .map(|(path_path, probed)| ShadowedRsync {
716                path: path_path.clone(),
717                flavor: probed
718                    .as_deref()
719                    .map_or(RsyncFlavor::Unknown, RsyncFlavor::parse_version_output),
720            });
721        return Ok(ResolvedRsync {
722            path,
723            flavor,
724            version_line,
725            source: RsyncSource::PreferredLocation,
726            shadowed,
727        });
728    }
729
730    // Nothing modern anywhere: drive the PATH binary with the compatibility
731    // argv, or report that there is no rsync at all.
732    match path_candidate {
733        Some((path, Ok(version_line))) => Ok(ResolvedRsync {
734            flavor: RsyncFlavor::parse_version_output(&version_line),
735            path,
736            version_line,
737            source: RsyncSource::Path,
738            shadowed: None,
739        }),
740        Some((_, Err(error))) => Err(error),
741        None => Err(RsyncResolveError::NotFound),
742    }
743}
744
745fn same_file(a: &Path, b: &Path) -> bool {
746    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
747        (Ok(a), Ok(b)) => a == b,
748        _ => a == b,
749    }
750}
751
752type ResolveCache = Mutex<HashMap<Option<String>, Result<ResolvedRsync, RsyncResolveError>>>;
753
754fn resolve_cache() -> &'static ResolveCache {
755    static CACHE: OnceLock<ResolveCache> = OnceLock::new();
756    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
757}
758
759/// [`resolve_rsync`] memoized per process, keyed by the configured override.
760///
761/// Errors are cached too: a missing binary does not appear between two
762/// transfers of the same job, and re-probing on every builder call would
763/// only repeat the same failed spawn.
764pub fn resolve_rsync_cached(configured: Option<&str>) -> Result<ResolvedRsync, RsyncResolveError> {
765    let key = configured.map(str::to_string);
766    if let Ok(cache) = resolve_cache().lock()
767        && let Some(cached) = cache.get(&key)
768    {
769        return cached.clone();
770    }
771    let resolved = resolve_rsync(configured);
772    if let Ok(mut cache) = resolve_cache().lock() {
773        cache.insert(key, resolved.clone());
774    }
775    resolved
776}
777
778/// Drop the per-process resolution cache (tests, or after `rch config`
779/// rewrites `rsync_bin` inside a long-lived process).
780pub fn clear_resolve_cache() {
781    if let Ok(mut cache) = resolve_cache().lock() {
782        cache.clear();
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn remote_path_arg_version_boundary_and_plain_path_control() {
792        let versions = [
793            ((2, 6, 9), false),
794            ((3, 0, 9), false),
795            ((3, 1, 3), false),
796            ((3, 2, 3), false),
797            ((3, 2, 4), true),
798            ((3, 2, 7), true),
799            ((3, 4, 1), true),
800            ((4, 0, 0), true),
801        ];
802        for ((major, minor, patch), modern) in versions {
803            let flavor = RsyncFlavor::Rsync {
804                major,
805                minor,
806                patch,
807            };
808            assert_eq!(flavor.escapes_remote_args(), modern, "{flavor}");
809        }
810        assert!(!RsyncFlavor::OpenRsync { protocol: Some(29) }.escapes_remote_args());
811        assert!(RsyncFlavor::Unknown.escapes_remote_args());
812        for (flavor, modern) in [
813            (RsyncFlavor::OpenRsync { protocol: Some(29) }, false),
814            (RsyncFlavor::Unknown, true),
815        ] {
816            assert_eq!(flavor.escapes_remote_args(), modern, "{flavor}");
817            assert_eq!(
818                flavor.remote_path_arg("/data/projects/plain/"),
819                "/data/projects/plain/"
820            );
821            for path in ["/data/projects/p q/", "/data/projects/x:y/"] {
822                let expected = if modern {
823                    path.to_string()
824                } else {
825                    format!("'{path}'")
826                };
827                assert_eq!(flavor.remote_path_arg(path), expected, "{flavor}");
828            }
829        }
830    }
831
832    #[test]
833    fn remote_path_arg_preserves_literal_metacharacters() {
834        let flavor = RsyncFlavor::Rsync {
835            major: 3,
836            minor: 2,
837            patch: 4,
838        };
839        for (path, expected) in [
840            ("/data/p'q:x/", "/data/p'q:x/"),
841            (
842                "/data/$(touch marker);$HOME/",
843                "/data/$(touch marker);$HOME/",
844            ),
845            (r"/data/a\b/", r"/data/a\b/"),
846            ("/data/a[1]/", "./"),
847            ("/data/a*?/", "./"),
848            (r"/data/a\[1]/", "./"),
849            ("/data/a*/archive.tar", "."),
850            ("/data/p q/out:x/", "/data/p q/out:x/"),
851        ] {
852            assert_eq!(flavor.remote_path_arg(path), expected);
853        }
854    }
855
856    #[test]
857    fn remote_argument_mode_overrides_are_child_local() {
858        use std::ffi::OsStr;
859
860        let mut command = Command::new("rsync");
861        command
862            .env("RSYNC_OLD_ARGS", "2")
863            .env("RSYNC_PROTECT_ARGS", "1");
864        configure_rsync_remote_args(&mut command);
865        for key in ["RSYNC_OLD_ARGS", "RSYNC_PROTECT_ARGS"] {
866            assert_eq!(
867                command.get_envs().find(|(name, _)| *name == key),
868                Some((OsStr::new(key), Some(OsStr::new("0"))))
869            );
870        }
871    }
872
873    /// Exercise the encoder against real rsync and a real remote shell parser,
874    /// without requiring an SSH server or a configured compilation worker.
875    #[cfg(unix)]
876    #[test]
877    fn remote_path_arg_real_rsync_round_trip() {
878        use std::os::unix::fs::{PermissionsExt, symlink};
879
880        let resolved = match resolve_rsync_cached(None) {
881            Ok(resolved) => resolved,
882            Err(RsyncResolveError::NotFound) => {
883                eprintln!("skipping real rsync regression: rsync is not installed");
884                return;
885            }
886            Err(error) => panic!("cannot resolve test rsync: {error}"),
887        };
888        let temp = tempfile::tempdir().expect("fixture directory");
889        let rsh = temp.path().join("rsh");
890        // Rsync supplies the host as argv[1]. Like SSH, the transport parses the
891        // remaining command as a shell string, not as already separated argv.
892        std::fs::write(&rsh, "#!/bin/sh\nshift\nexec /bin/sh -c \"$*\"\n")
893            .expect("write remote shell");
894        std::fs::set_permissions(&rsh, std::fs::Permissions::from_mode(0o700))
895            .expect("executable remote shell");
896        let run = |flags: &[&str], source: &str, destination: &str, remote: &str| {
897            let mut command = Command::new(&resolved.path);
898            command
899                .env("RSYNC_OLD_ARGS", "1")
900                .env("RSYNC_PROTECT_ARGS", "1");
901            configure_rsync_remote_args(&mut command);
902            let output = command
903                .env("LC_ALL", "C")
904                .args(flags)
905                .arg("-e")
906                .arg(shell_escape::escape(rsh.to_string_lossy()).as_ref())
907                .arg("--rsync-path")
908                .arg(resolved.flavor.remote_path_command(
909                    shell_escape::escape(resolved.path.to_string_lossy()).into_owned(),
910                    remote,
911                ))
912                .arg(source)
913                .arg(destination)
914                .output()
915                .expect("run rsync");
916            assert!(
917                output.status.success(),
918                "rsync {flags:?} {source:?} {destination:?}: {}",
919                String::from_utf8_lossy(&output.stderr)
920            );
921            output
922        };
923        let worker = temp.path().join("worker");
924        std::fs::create_dir_all(worker.join("a1")).expect("shadow directory");
925        std::fs::write(worker.join("a1/payload"), "wrong sibling").expect("shadow file");
926        let secret = temp.path().join("outside-secret");
927        std::fs::write(&secret, "must not be retrieved").expect("outside file");
928        for name in ["plain", "p q", "x:y", "a[1]", "a*", r"back\*"] {
929            let source = temp.path().join("source").join(name);
930            let remote = worker.join(name);
931            let local = temp.path().join("retrieved").join(name);
932            std::fs::create_dir_all(&source).expect("source directory");
933            std::fs::create_dir_all(&local).expect("retrieval directory");
934            std::fs::write(source.join("payload"), name).expect("source payload");
935            let remote_raw = format!("{}/", remote.display());
936            let remote_operand =
937                format!("loopback:{}", resolved.flavor.remote_path_arg(&remote_raw));
938            let source_operand = format!("{}/", source.display());
939            run(&["-a"], &source_operand, &remote_operand, &remote_raw);
940            assert_eq!(
941                std::fs::read(remote.join("payload")).unwrap(),
942                name.as_bytes()
943            );
944            let barrier = run(
945                &["-a", "--checksum", "--dry-run", "--itemize-changes"],
946                &source_operand,
947                &remote_operand,
948                &remote_raw,
949            );
950            assert!(barrier.stdout.is_empty(), "no-delta barrier: {name}");
951            symlink(&secret, remote.join("escape-link")).expect("unsafe symlink fixture");
952            run(
953                &["-a", "--safe-links"],
954                &remote_operand,
955                &format!("{}/", local.display()),
956                &remote_raw,
957            );
958            assert_eq!(
959                std::fs::read(local.join("payload")).unwrap(),
960                name.as_bytes()
961            );
962            assert!(std::fs::symlink_metadata(local.join("escape-link")).is_err());
963            let result = remote.join("out p:q");
964            std::fs::create_dir_all(&result).expect("result directory");
965            std::fs::write(result.join("result"), name).expect("result payload");
966            run(
967                &["-a", "--safe-links"],
968                &format!(
969                    "loopback:{}",
970                    resolved
971                        .flavor
972                        .remote_path_arg(&format!("{}/", result.display()))
973                ),
974                &format!("{}/out p:q/", local.display()),
975                &format!("{}/", result.display()),
976            );
977            assert_eq!(
978                std::fs::read(local.join("out p:q/result")).unwrap(),
979                name.as_bytes()
980            );
981            let archive_raw = format!("{}/archive.tar", remote.display());
982            run(
983                &["-a"],
984                source.join("payload").to_str().unwrap(),
985                &format!("loopback:{}", resolved.flavor.remote_path_arg(&archive_raw)),
986                &archive_raw,
987            );
988            assert_eq!(
989                std::fs::read(remote.join("archive.tar")).unwrap(),
990                name.as_bytes()
991            );
992        }
993    }
994
995    /// Captured from Homebrew rsync 3.4.1 on macOS.
996    const RSYNC_3_4_1: &str = "rsync  version 3.4.1  protocol version 32\n\
997Copyright (C) 1996-2025 by Andrew Tridgell, Wayne Davison, and others.\n\
998Web site: https://rsync.samba.org/\n\
999Capabilities:\n    64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,\n";
1000
1001    /// Captured from Debian bookworm's rsync 3.2.7.
1002    const RSYNC_3_2_7: &str = "rsync  version 3.2.7  protocol version 31\n\
1003Copyright (C) 1996-2022 by Andrew Tridgell, Wayne Davison, and others.\n";
1004
1005    /// Captured from stock macOS `/usr/bin/rsync` (Sequoia and later).
1006    const OPENRSYNC_MACOS: &str =
1007        "openrsync: protocol version 29\nrsync version 2.6.9 compatible\n";
1008
1009    /// Apple's legacy rsync build (macOS up to Sonoma).
1010    const RSYNC_2_6_9_APPLE: &str = "rsync  version 2.6.9  protocol version 29\n\
1011Copyright (C) 1996-2006 by Andrew Tridgell, Wayne Davison, and others.\n";
1012
1013    #[test]
1014    fn parses_upstream_3x_versions() {
1015        assert_eq!(
1016            RsyncFlavor::parse_version_output(RSYNC_3_4_1),
1017            RsyncFlavor::Rsync {
1018                major: 3,
1019                minor: 4,
1020                patch: 1
1021            }
1022        );
1023        assert_eq!(
1024            RsyncFlavor::parse_version_output(RSYNC_3_2_7),
1025            RsyncFlavor::Rsync {
1026                major: 3,
1027                minor: 2,
1028                patch: 7
1029            }
1030        );
1031        assert_eq!(
1032            RsyncFlavor::parse_version_output("rsync  version 3.2.7pre1  protocol version 31"),
1033            RsyncFlavor::Rsync {
1034                major: 3,
1035                minor: 2,
1036                patch: 7
1037            }
1038        );
1039        assert_eq!(
1040            RsyncFlavor::parse_version_output("rsync  version 3.5.0dev  protocol version 32"),
1041            RsyncFlavor::Rsync {
1042                major: 3,
1043                minor: 5,
1044                patch: 0
1045            }
1046        );
1047    }
1048
1049    #[test]
1050    fn parses_openrsync_and_does_not_mistake_its_compat_line_for_2_6_9() {
1051        assert_eq!(
1052            RsyncFlavor::parse_version_output(OPENRSYNC_MACOS),
1053            RsyncFlavor::OpenRsync { protocol: Some(29) }
1054        );
1055        assert_eq!(
1056            RsyncFlavor::parse_version_output("openrsync: protocol version"),
1057            RsyncFlavor::OpenRsync { protocol: None }
1058        );
1059    }
1060
1061    #[test]
1062    fn parses_apple_legacy_2_6_9() {
1063        assert_eq!(
1064            RsyncFlavor::parse_version_output(RSYNC_2_6_9_APPLE),
1065            RsyncFlavor::Rsync {
1066                major: 2,
1067                minor: 6,
1068                patch: 9
1069            }
1070        );
1071    }
1072
1073    #[test]
1074    fn unrecognized_banner_is_unknown_and_assumed_modern() {
1075        for text in [
1076            "",
1077            "\n\n",
1078            "rsync: command not found",
1079            "usage: rsync [-0468BC...]",
1080        ] {
1081            assert_eq!(
1082                RsyncFlavor::parse_version_output(text),
1083                RsyncFlavor::Unknown,
1084                "{text:?}"
1085            );
1086        }
1087        assert_eq!(
1088            RsyncFlavor::Unknown.capabilities(),
1089            RsyncCapabilities::MODERN
1090        );
1091        assert!(RsyncFlavor::Unknown.is_supported());
1092    }
1093
1094    #[test]
1095    fn capabilities_follow_version_thresholds() {
1096        let v = |major, minor| RsyncFlavor::Rsync {
1097            major,
1098            minor,
1099            patch: 0,
1100        };
1101        assert_eq!(v(3, 4).capabilities(), RsyncCapabilities::MODERN);
1102        assert_eq!(v(3, 2).capabilities(), RsyncCapabilities::MODERN);
1103        let three_one = v(3, 1).capabilities();
1104        assert!(three_one.info_flags);
1105        assert!(three_one.stats_regular_file_breakdown);
1106        assert!(!three_one.compress_choice);
1107        assert!(three_one.append_verify);
1108        let three_zero = v(3, 0).capabilities();
1109        assert!(!three_zero.info_flags);
1110        assert!(!three_zero.compress_choice);
1111        assert!(three_zero.append_verify);
1112        assert!(three_zero.no_motd);
1113        assert_eq!(v(2, 6).capabilities(), RsyncCapabilities::LEGACY);
1114        let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
1115        assert!(!openrsync.info_flags);
1116        assert!(!openrsync.compress_choice);
1117        assert!(!openrsync.append_verify);
1118        assert!(openrsync.no_motd);
1119        assert!(openrsync.is_compatibility_mode());
1120        assert!(!RsyncCapabilities::MODERN.is_compatibility_mode());
1121    }
1122
1123    #[test]
1124    fn support_floor_is_2_6_9() {
1125        let v = |major, minor, patch| RsyncFlavor::Rsync {
1126            major,
1127            minor,
1128            patch,
1129        };
1130        assert!(v(2, 6, 9).is_supported());
1131        assert!(v(3, 0, 0).is_supported());
1132        assert!(!v(2, 6, 8).is_supported());
1133        assert!(!v(2, 5, 7).is_supported());
1134        assert!(RsyncFlavor::OpenRsync { protocol: None }.is_supported());
1135    }
1136
1137    #[test]
1138    fn argv_fragments_per_flavour() {
1139        let modern = RsyncCapabilities::MODERN;
1140        assert_eq!(modern.progress_args(), ["--info=progress2"]);
1141        assert_eq!(modern.stats_args(), ["--info=stats2"]);
1142        assert_eq!(modern.name_listing_args(), ["--info=name2"]);
1143        assert_eq!(modern.resume_args(), ["--partial", "--append-verify"]);
1144        assert_eq!(modern.no_motd_args(), ["--no-motd"]);
1145        assert_eq!(
1146            modern.compression_args(7),
1147            ["--compress-choice=zstd", "--compress-level=7"]
1148        );
1149        assert_eq!(
1150            modern.compression_args(19),
1151            ["--compress-choice=zstd", "--compress-level=19"]
1152        );
1153        assert!(modern.compression_args(0).is_empty());
1154
1155        let legacy = RsyncCapabilities::LEGACY;
1156        assert_eq!(legacy.progress_args(), ["--progress"]);
1157        assert_eq!(legacy.stats_args(), ["--stats"]);
1158        assert_eq!(legacy.name_listing_args(), ["-vv"]);
1159        assert_eq!(legacy.resume_args(), ["--partial"]);
1160        assert!(legacy.no_motd_args().is_empty());
1161        assert_eq!(legacy.compression_args(7), ["--compress-level=7"]);
1162        // zlib tops out at 9; a zstd-tuned level must not be passed through.
1163        assert_eq!(legacy.compression_args(19), ["--compress-level=9"]);
1164        assert!(legacy.compression_args(0).is_empty());
1165
1166        let openrsync = RsyncFlavor::OpenRsync { protocol: Some(29) }.capabilities();
1167        assert_eq!(openrsync.no_motd_args(), ["--no-motd"]);
1168        assert_eq!(openrsync.progress_args(), ["--progress"]);
1169    }
1170
1171    #[test]
1172    fn display_and_describe() {
1173        assert_eq!(
1174            RsyncFlavor::Rsync {
1175                major: 3,
1176                minor: 4,
1177                patch: 1
1178            }
1179            .to_string(),
1180            "rsync 3.4.1"
1181        );
1182        assert_eq!(
1183            RsyncFlavor::OpenRsync { protocol: Some(29) }.to_string(),
1184            "openrsync (protocol 29)"
1185        );
1186        let resolved = ResolvedRsync {
1187            path: PathBuf::from("/opt/homebrew/bin/rsync"),
1188            flavor: RsyncFlavor::Rsync {
1189                major: 3,
1190                minor: 4,
1191                patch: 1,
1192            },
1193            version_line: "rsync  version 3.4.1  protocol version 32".to_string(),
1194            source: RsyncSource::PreferredLocation,
1195            shadowed: Some(ShadowedRsync {
1196                path: PathBuf::from("/usr/bin/rsync"),
1197                flavor: RsyncFlavor::OpenRsync { protocol: Some(29) },
1198            }),
1199        };
1200        assert_eq!(
1201            resolved.describe(),
1202            "rsync 3.4.1 at /opt/homebrew/bin/rsync (well-known install location); \
1203             PATH rsync is openrsync (protocol 29) at /usr/bin/rsync"
1204        );
1205    }
1206
1207    #[test]
1208    fn override_path_expands_tilde_and_keeps_absolute_paths() {
1209        assert_eq!(
1210            override_path("/usr/bin/rsync"),
1211            Some(PathBuf::from("/usr/bin/rsync"))
1212        );
1213        let home = override_path("~/bin/rsync").expect("expanded");
1214        assert!(!home.to_string_lossy().starts_with('~'));
1215        assert!(home.ends_with("bin/rsync"));
1216        assert_eq!(override_path("   "), None);
1217    }
1218
1219    #[test]
1220    fn explicit_override_to_missing_binary_is_an_error() {
1221        let missing =
1222            std::env::temp_dir().join(format!("rch-rsync-missing-{}", uuid::Uuid::new_v4()));
1223        let error = resolve_rsync(Some(missing.to_str().expect("utf-8"))).expect_err("missing");
1224        assert_eq!(
1225            error,
1226            RsyncResolveError::OverrideMissing {
1227                origin: RsyncSource::Config,
1228                path: missing,
1229            }
1230        );
1231    }
1232
1233    #[cfg(unix)]
1234    #[test]
1235    fn explicit_override_is_honoured_whatever_its_flavour() {
1236        use std::os::unix::fs::PermissionsExt;
1237        let dir = tempfile::tempdir().expect("tempdir");
1238        let fake = dir.path().join("rsync");
1239        std::fs::write(
1240            &fake,
1241            "#!/bin/sh\necho 'openrsync: protocol version 29'\necho 'rsync version 2.6.9 compatible'\n",
1242        )
1243        .expect("write");
1244        std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1245
1246        let resolved = resolve_rsync(Some(fake.to_str().expect("utf-8"))).expect("resolved");
1247        assert_eq!(resolved.path, fake);
1248        assert_eq!(resolved.source, RsyncSource::Config);
1249        assert_eq!(
1250            resolved.flavor,
1251            RsyncFlavor::OpenRsync { protocol: Some(29) }
1252        );
1253        assert_eq!(resolved.version_line, "openrsync: protocol version 29");
1254        assert!(resolved.capabilities().is_compatibility_mode());
1255        assert!(resolved.shadowed.is_none());
1256    }
1257
1258    #[cfg(unix)]
1259    #[test]
1260    fn probe_reports_binaries_that_print_nothing() {
1261        use std::os::unix::fs::PermissionsExt;
1262        let dir = tempfile::tempdir().expect("tempdir");
1263        let silent = dir.path().join("rsync");
1264        std::fs::write(&silent, "#!/bin/sh\nexit 0\n").expect("write");
1265        std::fs::set_permissions(&silent, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1266        let error = probe_rsync_version(&silent).expect_err("silent");
1267        assert!(
1268            matches!(error, RsyncResolveError::ProbeFailed { reason, .. } if reason == "no version output")
1269        );
1270    }
1271
1272    #[test]
1273    fn cached_resolution_is_stable_within_a_process() {
1274        clear_resolve_cache();
1275        let first = resolve_rsync_cached(None);
1276        let second = resolve_rsync_cached(None);
1277        assert_eq!(first, second);
1278        if let Ok(resolved) = first {
1279            assert!(resolved.path.is_absolute(), "{}", resolved.path.display());
1280            assert!(!resolved.version_line.is_empty());
1281        }
1282    }
1283}