diff --git a/crates/cli/src/frontend/arguments/parsed_args.rs b/crates/cli/src/frontend/arguments/parsed_args.rs index 53a69653b..4294fa7cc 100644 --- a/crates/cli/src/frontend/arguments/parsed_args.rs +++ b/crates/cli/src/frontend/arguments/parsed_args.rs @@ -87,6 +87,9 @@ pub(crate) struct ParsedArgs { pub(crate) temp_dir: Option, pub(crate) log_file: Option, pub(crate) log_file_format: Option, + pub(crate) write_batch: Option, + pub(crate) only_write_batch: Option, + pub(crate) read_batch: Option, pub(crate) link_dests: Vec, pub(crate) remove_source_files: bool, pub(crate) inplace: Option, diff --git a/crates/cli/src/frontend/arguments/parser.rs b/crates/cli/src/frontend/arguments/parser.rs index 86ac4e6ef..0c436bf92 100644 --- a/crates/cli/src/frontend/arguments/parser.rs +++ b/crates/cli/src/frontend/arguments/parser.rs @@ -309,6 +309,9 @@ where .map(PathBuf::from); let log_file = matches.remove_one::("log-file"); let log_file_format = matches.remove_one::("log-file-format"); + let write_batch = matches.remove_one::("write-batch"); + let only_write_batch = matches.remove_one::("only-write-batch"); + let read_batch = matches.remove_one::("read-batch"); let link_dest_args: Vec = matches .remove_many::("link-dest") .map(|values| values.collect()) @@ -540,6 +543,9 @@ where temp_dir, log_file, log_file_format, + write_batch, + only_write_batch, + read_batch, link_dests, remove_source_files, inplace, diff --git a/crates/cli/src/frontend/command_builder/sections/section_02.rs b/crates/cli/src/frontend/command_builder/sections/section_02.rs index 9ccb10832..6968ee3e3 100644 --- a/crates/cli/src/frontend/command_builder/sections/section_02.rs +++ b/crates/cli/src/frontend/command_builder/sections/section_02.rs @@ -32,6 +32,30 @@ pub(crate) fn section_02(command: ClapCommand) -> ClapCommand { .help("Customise the format used when appending to --log-file.") .value_parser(OsStringValueParser::new()), ) + .arg( + Arg::new("write-batch") + .long("write-batch") + .value_name("PREFIX") + .help("Store updated data in batch files named PREFIX for later replay.") + .value_parser(OsStringValueParser::new()) + .conflicts_with_all(["read-batch", "only-write-batch"]), + ) + .arg( + Arg::new("only-write-batch") + .long("only-write-batch") + .value_name("PREFIX") + .help("Write batch files named PREFIX without applying the updates locally.") + .value_parser(OsStringValueParser::new()) + .conflicts_with_all(["read-batch", "write-batch"]), + ) + .arg( + Arg::new("read-batch") + .long("read-batch") + .value_name("PREFIX") + .help("Apply updates stored in batch files named PREFIX.") + .value_parser(OsStringValueParser::new()) + .conflicts_with_all(["write-batch", "only-write-batch"]), + ) .arg( Arg::new("whole-file") .long("whole-file") diff --git a/crates/cli/src/frontend/execution/drive/config.rs b/crates/cli/src/frontend/execution/drive/config.rs index b743fd1c0..d26942769 100644 --- a/crates/cli/src/frontend/execution/drive/config.rs +++ b/crates/cli/src/frontend/execution/drive/config.rs @@ -84,6 +84,7 @@ pub(crate) struct ConfigInputs { pub(crate) append: bool, pub(crate) append_verify: bool, pub(crate) whole_file: bool, + pub(crate) force_fallback: bool, pub(crate) timeout: TransferTimeout, pub(crate) connect_timeout: TransferTimeout, pub(crate) stop_deadline: Option, @@ -175,6 +176,7 @@ pub(crate) fn build_base_config(mut inputs: ConfigInputs) -> ClientConfigBuilder .append(inputs.append) .append_verify(inputs.append_verify) .whole_file(inputs.whole_file) + .force_fallback(inputs.force_fallback) .timeout(inputs.timeout) .connect_timeout(inputs.connect_timeout) .stop_at(inputs.stop_deadline); diff --git a/crates/cli/src/frontend/execution/drive/fallback.rs b/crates/cli/src/frontend/execution/drive/fallback.rs index 01ee0249e..9abca4a98 100644 --- a/crates/cli/src/frontend/execution/drive/fallback.rs +++ b/crates/cli/src/frontend/execution/drive/fallback.rs @@ -126,6 +126,9 @@ pub(crate) struct FallbackInputs { #[cfg(feature = "xattr")] pub(crate) xattrs: Option, pub(crate) itemize_changes: bool, + pub(crate) write_batch: Option, + pub(crate) only_write_batch: Option, + pub(crate) read_batch: Option, } /// Builds the remote fallback arguments when required. @@ -265,6 +268,9 @@ where fallback_binary: None, rsync_path: inputs.rsync_path, remainder: inputs.remainder, + write_batch: inputs.write_batch, + only_write_batch: inputs.only_write_batch, + read_batch: inputs.read_batch, #[cfg(feature = "acl")] acls: inputs.acls, #[cfg(feature = "xattr")] diff --git a/crates/cli/src/frontend/execution/drive/workflow/fallback_plan.rs b/crates/cli/src/frontend/execution/drive/workflow/fallback_plan.rs index 84fc58d3a..c4a5dd332 100644 --- a/crates/cli/src/frontend/execution/drive/workflow/fallback_plan.rs +++ b/crates/cli/src/frontend/execution/drive/workflow/fallback_plan.rs @@ -121,6 +121,9 @@ pub(crate) struct FallbackArgumentsContext<'a> { pub(crate) itemize_changes: bool, pub(crate) log_file_path: Option<&'a PathBuf>, pub(crate) log_file_format: Option<&'a OsString>, + pub(crate) write_batch: Option<&'a OsString>, + pub(crate) only_write_batch: Option<&'a OsString>, + pub(crate) read_batch: Option<&'a OsString>, } pub(crate) fn build_fallback_arguments( @@ -256,6 +259,9 @@ where address_mode: context.address_mode, rsync_path: context.rsync_path.cloned(), remainder: context.remainder.to_vec(), + write_batch: context.write_batch.cloned(), + only_write_batch: context.only_write_batch.cloned(), + read_batch: context.read_batch.cloned(), #[cfg(feature = "acl")] acls: context.acls, #[cfg(feature = "xattr")] diff --git a/crates/cli/src/frontend/execution/drive/workflow/mod.rs b/crates/cli/src/frontend/execution/drive/workflow/mod.rs index 0389596eb..d4bf88893 100644 --- a/crates/cli/src/frontend/execution/drive/workflow/mod.rs +++ b/crates/cli/src/frontend/execution/drive/workflow/mod.rs @@ -138,6 +138,9 @@ where temp_dir, log_file, log_file_format, + write_batch, + only_write_batch, + read_batch, link_dests, remove_source_files, inplace, @@ -330,8 +333,10 @@ where let files_from_used = !files_from.is_empty(); let implied_dirs_option = implied_dirs; let implied_dirs = implied_dirs_option.unwrap_or(true); + let batch_mode_requested = + write_batch.is_some() || only_write_batch.is_some() || read_batch.is_some(); let requires_remote_fallback = transfer_requires_remote(&remainder, &file_list_operands); - let fallback_required = requires_remote_fallback; + let fallback_required = requires_remote_fallback || batch_mode_requested; let fallback_context = FallbackArgumentsContext { required: fallback_required, @@ -440,6 +445,9 @@ where itemize_changes, log_file_path: log_file_path_buf.as_ref(), log_file_format: log_file_format_cli.as_ref(), + write_batch: write_batch.as_ref(), + only_write_batch: only_write_batch.as_ref(), + read_batch: read_batch.as_ref(), }; let fallback_args = match build_fallback_arguments(fallback_context, stderr) { Ok(args) => args, @@ -609,6 +617,7 @@ where append: append_enabled, append_verify, whole_file: whole_file_enabled, + force_fallback: batch_mode_requested, timeout: timeout_setting, connect_timeout: connect_timeout_setting, stop_deadline: stop_request.as_ref().map(StopRequest::deadline), diff --git a/crates/cli/src/frontend/tests/local.rs b/crates/cli/src/frontend/tests/local.rs index 8cef93bfe..f3ec19603 100644 --- a/crates/cli/src/frontend/tests/local.rs +++ b/crates/cli/src/frontend/tests/local.rs @@ -45,3 +45,45 @@ exit 0 let recorded = std::fs::read_to_string(&args_path).expect("read args file"); assert_eq!(recorded, "untouched"); } + +#[cfg(unix)] +#[test] +fn local_write_batch_forces_fallback_path() { + use tempfile::tempdir; + + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _rsh_guard = clear_rsync_rsh(); + let temp = tempdir().expect("tempdir"); + let script_path = temp.path().join("fallback.sh"); + let args_path = temp.path().join("args.txt"); + + let script = r#"#!/bin/sh +printf "%s\n" "$@" > "$ARGS_FILE" +exit 0 +"#; + write_executable_script(&script_path, script); + + let _fallback_guard = EnvGuard::set(CLIENT_FALLBACK_ENV, script_path.as_os_str()); + let _args_guard = EnvGuard::set("ARGS_FILE", args_path.as_os_str()); + + std::fs::write(&args_path, b"").expect("truncate args file"); + + let source = temp.path().join("source.txt"); + let destination = temp.path().join("dest.txt"); + std::fs::write(&source, b"contents").expect("write source"); + + let (code, stdout, stderr) = run_with_args([ + OsString::from(RSYNC), + OsString::from("--write-batch=batch"), + source.into_os_string(), + destination.into_os_string(), + ]); + + assert_eq!(code, 0); + assert!(stdout.is_empty()); + assert!(stderr.is_empty()); + + let recorded = std::fs::read_to_string(&args_path).expect("read args file"); + let args: Vec<&str> = recorded.lines().collect(); + assert!(args.contains(&"--write-batch=batch")); +} diff --git a/crates/cli/src/frontend/tests/mod.rs b/crates/cli/src/frontend/tests/mod.rs index 200e3144c..f14289740 100644 --- a/crates/cli/src/frontend/tests/mod.rs +++ b/crates/cli/src/frontend/tests/mod.rs @@ -104,6 +104,8 @@ mod parse_args_reads_tests; mod parse_args_recognises_append_tests; #[path = "parse_args_recognises_archive.rs"] mod parse_args_recognises_archive_tests; +#[path = "parse_args_recognises_batch.rs"] +mod parse_args_recognises_batch_tests; #[path = "parse_args_recognises_block_size.rs"] mod parse_args_recognises_block_size_tests; #[path = "parse_args_recognises_checksum.rs"] @@ -232,6 +234,8 @@ mod remote_fallback_forwards_acls_tests; mod remote_fallback_forwards_append_tests; #[path = "remote_fallback_forwards_backup.rs"] mod remote_fallback_forwards_backup_tests; +#[path = "remote_fallback_forwards_batch.rs"] +mod remote_fallback_forwards_batch_tests; #[path = "remote_fallback_forwards_compress.rs"] mod remote_fallback_forwards_compress_tests; #[path = "remote_fallback_forwards_connect.rs"] diff --git a/crates/cli/src/frontend/tests/parse_args_recognises_batch.rs b/crates/cli/src/frontend/tests/parse_args_recognises_batch.rs new file mode 100644 index 000000000..73d195da3 --- /dev/null +++ b/crates/cli/src/frontend/tests/parse_args_recognises_batch.rs @@ -0,0 +1,47 @@ +use super::common::*; +use super::*; + +#[test] +fn parse_args_recognises_write_batch_prefix() { + let parsed = parse_args([ + OsString::from(RSYNC), + OsString::from("--write-batch=updates"), + OsString::from("source"), + OsString::from("dest"), + ]) + .expect("parse"); + + assert_eq!(parsed.write_batch, Some(OsString::from("updates"))); + assert!(parsed.only_write_batch.is_none()); + assert!(parsed.read_batch.is_none()); +} + +#[test] +fn parse_args_recognises_only_write_batch_prefix() { + let parsed = parse_args([ + OsString::from(RSYNC), + OsString::from("--only-write-batch=batch"), + OsString::from("source"), + OsString::from("dest"), + ]) + .expect("parse"); + + assert_eq!(parsed.only_write_batch, Some(OsString::from("batch"))); + assert!(parsed.write_batch.is_none()); + assert!(parsed.read_batch.is_none()); +} + +#[test] +fn parse_args_recognises_read_batch_prefix() { + let parsed = parse_args([ + OsString::from(RSYNC), + OsString::from("--read-batch=replay"), + OsString::from("source"), + OsString::from("dest"), + ]) + .expect("parse"); + + assert_eq!(parsed.read_batch, Some(OsString::from("replay"))); + assert!(parsed.write_batch.is_none()); + assert!(parsed.only_write_batch.is_none()); +} diff --git a/crates/cli/src/frontend/tests/remote_fallback_forwards_batch.rs b/crates/cli/src/frontend/tests/remote_fallback_forwards_batch.rs new file mode 100644 index 000000000..13c17a8dc --- /dev/null +++ b/crates/cli/src/frontend/tests/remote_fallback_forwards_batch.rs @@ -0,0 +1,74 @@ +use super::common::*; +use super::*; + +#[cfg(unix)] +#[test] +fn remote_fallback_forwards_batch_arguments() { + use tempfile::tempdir; + + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _rsh_guard = clear_rsync_rsh(); + let temp = tempdir().expect("tempdir"); + let script_path = temp.path().join("fallback.sh"); + let args_path = temp.path().join("args.txt"); + + let script = r#"#!/bin/sh +printf "%s\n" "$@" > "$ARGS_FILE" +exit 0 +"#; + write_executable_script(&script_path, script); + + let _fallback_guard = EnvGuard::set(CLIENT_FALLBACK_ENV, script_path.as_os_str()); + let _args_guard = EnvGuard::set("ARGS_FILE", args_path.as_os_str()); + + let dest = temp.path().join("dest"); + + let (code, stdout, stderr) = run_with_args([ + OsString::from(RSYNC), + OsString::from("--write-batch=batch"), + OsString::from("remote::module"), + dest.clone().into_os_string(), + ]); + + assert_eq!(code, 0); + assert!(stdout.is_empty()); + assert!(stderr.is_empty()); + + let recorded = std::fs::read_to_string(&args_path).expect("read args file"); + let args: Vec<&str> = recorded.lines().collect(); + assert!(args.contains(&"--write-batch=batch")); + + std::fs::write(&args_path, b"").expect("truncate args file"); + + let (code, stdout, stderr) = run_with_args([ + OsString::from(RSYNC), + OsString::from("--only-write-batch=batch"), + OsString::from("remote::module"), + dest.clone().into_os_string(), + ]); + + assert_eq!(code, 0); + assert!(stdout.is_empty()); + assert!(stderr.is_empty()); + + let recorded = std::fs::read_to_string(&args_path).expect("read args file"); + let args: Vec<&str> = recorded.lines().collect(); + assert!(args.contains(&"--only-write-batch=batch")); + + std::fs::write(&args_path, b"").expect("truncate args file"); + + let (code, stdout, stderr) = run_with_args([ + OsString::from(RSYNC), + OsString::from("--read-batch=batch"), + OsString::from("remote::module"), + dest.into_os_string(), + ]); + + assert_eq!(code, 0); + assert!(stdout.is_empty()); + assert!(stderr.is_empty()); + + let recorded = std::fs::read_to_string(&args_path).expect("read args file"); + let args: Vec<&str> = recorded.lines().collect(); + assert!(args.contains(&"--read-batch=batch")); +} diff --git a/crates/core/src/client/config/builder/fallback.rs b/crates/core/src/client/config/builder/fallback.rs new file mode 100644 index 000000000..924fccecc --- /dev/null +++ b/crates/core/src/client/config/builder/fallback.rs @@ -0,0 +1,17 @@ +use super::*; + +impl ClientConfigBuilder { + /// Forces the client orchestration to delegate to the legacy rsync binary. + /// + /// The native engine does not yet support batch file generation or replay, + /// so the CLI triggers delegation when `--write-batch`, + /// `--only-write-batch`, or `--read-batch` is supplied. Setting this flag + /// ensures [`run_client_or_fallback`](crate::client::run_client_or_fallback) + /// invokes the fallback even when the local plan would otherwise be + /// executable. + #[must_use] + pub const fn force_fallback(mut self, force: bool) -> Self { + self.force_fallback = force; + self + } +} diff --git a/crates/core/src/client/config/builder/mod.rs b/crates/core/src/client/config/builder/mod.rs index e40daa02c..e40d64f16 100644 --- a/crates/core/src/client/config/builder/mod.rs +++ b/crates/core/src/client/config/builder/mod.rs @@ -80,6 +80,7 @@ pub struct ClientConfigBuilder { append: bool, append_verify: bool, force_event_collection: bool, + force_fallback: bool, preserve_devices: bool, preserve_specials: bool, list_only: bool, @@ -166,6 +167,7 @@ impl ClientConfigBuilder { append: self.append, append_verify: self.append_verify, force_event_collection: self.force_event_collection, + force_fallback: self.force_fallback, preserve_devices: self.preserve_devices, preserve_specials: self.preserve_specials, list_only: self.list_only, @@ -187,6 +189,7 @@ impl ClientConfigBuilder { mod arguments; mod deletion; +mod fallback; mod filters; mod metadata; mod network; diff --git a/crates/core/src/client/config/client/fallback.rs b/crates/core/src/client/config/client/fallback.rs new file mode 100644 index 000000000..00a5da085 --- /dev/null +++ b/crates/core/src/client/config/client/fallback.rs @@ -0,0 +1,9 @@ +use super::*; + +impl ClientConfig { + /// Reports whether the client must delegate to the legacy rsync binary. + #[must_use] + pub const fn force_fallback(&self) -> bool { + self.force_fallback + } +} diff --git a/crates/core/src/client/config/client/mod.rs b/crates/core/src/client/config/client/mod.rs index e1a67b921..573e4c456 100644 --- a/crates/core/src/client/config/client/mod.rs +++ b/crates/core/src/client/config/client/mod.rs @@ -80,6 +80,7 @@ pub struct ClientConfig { pub(super) append: bool, pub(super) append_verify: bool, pub(super) force_event_collection: bool, + pub(super) force_fallback: bool, pub(super) preserve_devices: bool, pub(super) preserve_specials: bool, pub(super) list_only: bool, @@ -164,6 +165,7 @@ impl Default for ClientConfig { append: false, append_verify: false, force_event_collection: false, + force_fallback: false, preserve_devices: false, preserve_specials: false, list_only: false, @@ -193,6 +195,7 @@ impl ClientConfig { mod arguments; mod deletion; +mod fallback; mod filters; mod metadata; mod network; diff --git a/crates/core/src/client/error.rs b/crates/core/src/client/error.rs index 3fbaa5275..d716868b5 100644 --- a/crates/core/src/client/error.rs +++ b/crates/core/src/client/error.rs @@ -1,7 +1,9 @@ +use crate::fallback::{CLIENT_FALLBACK_ENV, describe_missing_fallback_binary}; use crate::message::{Message, Role}; use crate::rsync_error; use rsync_engine::local_copy::{LocalCopyError, LocalCopyErrorKind}; use std::error::Error; +use std::ffi::OsStr; use std::fmt; use std::io; use std::path::Path; @@ -59,6 +61,13 @@ pub(crate) fn missing_operands_error() -> ClientError { ClientError::new(FEATURE_UNAVAILABLE_EXIT_CODE, message) } +pub(crate) fn fallback_context_missing_error() -> ClientError { + let diagnostic = describe_missing_fallback_binary(OsStr::new("rsync"), &[CLIENT_FALLBACK_ENV]); + let text = format!("legacy rsync fallback required for this transfer: {diagnostic}"); + let message = rsync_error!(FEATURE_UNAVAILABLE_EXIT_CODE, text).with_role(Role::Client); + ClientError::new(FEATURE_UNAVAILABLE_EXIT_CODE, message) +} + pub(crate) fn invalid_argument_error(text: &str, exit_code: i32) -> ClientError { let message = rsync_error!(exit_code, "{}", text).with_role(Role::Client); ClientError::new(exit_code, message) diff --git a/crates/core/src/client/fallback/args.rs b/crates/core/src/client/fallback/args.rs index ab499c1d2..78b1791f7 100644 --- a/crates/core/src/client/fallback/args.rs +++ b/crates/core/src/client/fallback/args.rs @@ -243,6 +243,12 @@ pub struct RemoteFallbackArgs { pub rsync_path: Option, /// Remaining operands to forward to the fallback binary. pub remainder: Vec, + /// Optional prefix forwarded via `--write-batch`. + pub write_batch: Option, + /// Optional prefix forwarded via `--only-write-batch`. + pub only_write_batch: Option, + /// Optional prefix forwarded via `--read-batch`. + pub read_batch: Option, /// Controls ACL forwarding (`--acls`/`--no-acls`). #[cfg(feature = "acl")] pub acls: Option, diff --git a/crates/core/src/client/fallback/runner/command_builder.rs b/crates/core/src/client/fallback/runner/command_builder.rs index 98d920e0f..f3da2fd0d 100644 --- a/crates/core/src/client/fallback/runner/command_builder.rs +++ b/crates/core/src/client/fallback/runner/command_builder.rs @@ -129,6 +129,9 @@ pub(crate) fn prepare_invocation( fallback_binary, rsync_path, mut remainder, + write_batch, + only_write_batch, + read_batch, #[cfg(feature = "acl")] acls, #[cfg(feature = "xattr")] @@ -253,6 +256,21 @@ pub(crate) fn prepare_invocation( arg.push(value); command_args.push(arg); } + if let Some(prefix) = write_batch { + let mut arg = OsString::from("--write-batch="); + arg.push(prefix); + command_args.push(arg); + } + if let Some(prefix) = only_write_batch { + let mut arg = OsString::from("--only-write-batch="); + arg.push(prefix); + command_args.push(arg); + } + if let Some(prefix) = read_batch { + let mut arg = OsString::from("--read-batch="); + arg.push(prefix); + command_args.push(arg); + } if let Some(spec) = chown { let mut arg = OsString::from("--chown="); diff --git a/crates/core/src/client/run.rs b/crates/core/src/client/run.rs index f4a794aaf..4b0695edd 100644 --- a/crates/core/src/client/run.rs +++ b/crates/core/src/client/run.rs @@ -14,7 +14,8 @@ use super::config::{ ClientConfig, DeleteMode, FilterRuleKind, FilterRuleSpec, ReferenceDirectoryKind, }; use super::error::{ - ClientError, compile_filter_error, map_local_copy_error, missing_operands_error, + ClientError, compile_filter_error, fallback_context_missing_error, map_local_copy_error, + missing_operands_error, }; use super::fallback::{RemoteFallbackContext, run_remote_transfer_fallback}; use super::outcome::{ClientOutcome, FallbackSummary}; @@ -80,6 +81,13 @@ where let mut fallback = fallback; + if config.force_fallback() { + return fallback + .take() + .map(invoke_fallback) + .unwrap_or_else(|| Err(fallback_context_missing_error())); + } + let plan = match LocalCopyPlan::from_operands(config.transfer_args()) { Ok(plan) => plan, Err(error) => { diff --git a/crates/core/src/client/tests/client_event.rs b/crates/core/src/client/tests/client_event.rs index bbc037c49..e78935b14 100644 --- a/crates/core/src/client/tests/client_event.rs +++ b/crates/core/src/client/tests/client_event.rs @@ -355,6 +355,9 @@ fn baseline_fallback_args() -> RemoteFallbackArgs { fallback_binary: None, rsync_path: None, remainder: Vec::new(), + write_batch: None, + only_write_batch: None, + read_batch: None, #[cfg(feature = "acl")] acls: None, #[cfg(feature = "xattr")] diff --git a/crates/core/src/version/report/config.rs b/crates/core/src/version/report/config.rs index 7647b6e0e..177dd1d40 100644 --- a/crates/core/src/version/report/config.rs +++ b/crates/core/src/version/report/config.rs @@ -73,7 +73,7 @@ impl VersionInfoConfig { supports_hardlink_symlinks: cfg!(unix), supports_ipv6: true, supports_atimes: true, - supports_batchfiles: false, + supports_batchfiles: true, supports_inplace: true, supports_append: true, supports_acls: cfg!(feature = "acl"), diff --git a/docs/feature_matrix.md b/docs/feature_matrix.md index 35c384224..66d48933b 100644 --- a/docs/feature_matrix.md +++ b/docs/feature_matrix.md @@ -21,7 +21,7 @@ binary so documentation never overstates parity. | Core | Version metadata and standard banner formatting | Implemented | `version_metadata()` exposes upstream constants and renders the canonical `--version` banner (`rsync version 3.4.1-rust (revision/build #REV) protocol version 32`, copyright notice, web site, and build info line `Rust rsync implementation supporting protocol version 32`). | `crates/core/src/version/mod.rs` | | Core | Bandwidth limit parsing & pacing (`--bwlimit`) | Implemented | Shared helpers parse textual limits (decimal/binary/IEC units, fractional values, and `+1`/`-1` adjustments) and implement the token-bucket limiter reused by the client, engine, and daemon, including deterministic test instrumentation for pacing parity. | `crates/bandwidth/src/lib.rs`, `crates/core/src/client/mod.rs`, `crates/engine/src/local_copy.rs`, `crates/daemon/src/lib.rs` | | Logging | Message sinks with newline policy and scratch-buffer reuse | Implemented | `MessageSink` wraps `io::Write`, reuses `MessageScratch`, and mirrors upstream newline handling for diagnostics while providing mapping/flush helpers. | `crates/logging/src/lib.rs` | -| Workspace | CLI front-end (`oc-rsync` with `rsync` compatibility wrappers) | Partial | The canonical `oc-rsync` binary recognises the primary rsync options used in P1 scenarios: help/version toggles, daemon delegation, dry-run/list-only/archive (-a) and the deletion timing family plus deletion limits via `--max-delete`, backup toggles (`-b`/`--backup`, `--backup-dir`, `--suffix`), checksum/size/update gates (including modification-time tolerances via `--modify-window`), include/exclude/filter rules (including `hide`/`show`/`protect`, `exclude-if-present=FILE`, and `dir-merge` modifiers), file-list ingestion (`--files-from`/`--from0`), daemon authentication flags, relative/numeric-ID controls, filesystem traversal gating via `--one-file-system`/`-x`, bandwidth/timeouts (including `--bwlimit`/`--no-bwlimit`), daemon default port overrides via `--port`, size thresholds (`--min-size`/`--max-size`), compression toggles (including `--compress-choice` to select between zlib, lz4, and—when the workspace is built with the `zstd` feature—streaming Zstandard support, `--skip-compress` suffix lists, and the deterministic `OC_RSYNC_FORCE_NO_COMPRESS=1` guard used by the test harness), progress/stats/`--msgs2stderr`/`--out-format`, per-file logging via `--log-file`/`--log-file-format`, reference directory semantics (`--compare-dest`, `--copy-dest`, `--link-dest`), partial/`--preallocate`/inplace/`--append`/`--append-verify`/`--remove-source-files` switches, metadata flags (owner/group/perms/times/omit-dir-times/omit-link-times), sparse/device/special preservation, copy-links/copy-dirlinks, explicit parent directory creation via `--mkpath`, and ACL/xattr preservation (enabled by default but still gated behind build features for downstream portability). It performs deterministic local copies for regular files, directories, symbolic links, hard links, device nodes, FIFOs, and sparse files while preserving permissions, timestamps, optional ownership metadata, and (when the default `acl`/`xattr` features are enabled) extended attributes and POSIX ACLs; streams progress and stats; and lists `rsync://` modules via the legacy handshake while forwarding `--port` overrides through the module query path. Remote operands spawn the system `rsync` (`OC_RSYNC_FALLBACK`) until the native delta-transfer engine and full filter/compression parity land, and the CLI now emits an actionable diagnostic when the fallback binary is missing or not executable so operators can install upstream rsync or point `OC_RSYNC_FALLBACK` at a custom path before retrying. The `rsync` compatibility wrapper shares the same execution path so legacy packaging layouts continue to function. | `crates/cli`, `src/bin/rsync.rs`, `src/bin/oc-rsync.rs`, `crates/core/src/client/mod.rs` | +| Workspace | CLI front-end (`oc-rsync` with `rsync` compatibility wrappers) | Partial | The canonical `oc-rsync` binary recognises the primary rsync options used in P1 scenarios: help/version toggles, daemon delegation, dry-run/list-only/archive (-a) and the deletion timing family plus deletion limits via `--max-delete`, backup toggles (`-b`/`--backup`, `--backup-dir`, `--suffix`), checksum/size/update gates (including modification-time tolerances via `--modify-window`), include/exclude/filter rules (including `hide`/`show`/`protect`, `exclude-if-present=FILE`, and `dir-merge` modifiers), file-list ingestion (`--files-from`/`--from0`), daemon authentication flags, relative/numeric-ID controls, filesystem traversal gating via `--one-file-system`/`-x`, bandwidth/timeouts (including `--bwlimit`/`--no-bwlimit`), daemon default port overrides via `--port`, size thresholds (`--min-size`/`--max-size`), compression toggles (including `--compress-choice` to select between zlib, lz4, and—when the workspace is built with the `zstd` feature—streaming Zstandard support, `--skip-compress` suffix lists, and the deterministic `OC_RSYNC_FORCE_NO_COMPRESS=1` guard used by the test harness), progress/stats/`--msgs2stderr`/`--out-format`, per-file logging via `--log-file`/`--log-file-format`, reference directory semantics (`--compare-dest`, `--copy-dest`, `--link-dest`), partial/`--preallocate`/inplace/`--append`/`--append-verify`/`--remove-source-files` switches, metadata flags (owner/group/perms/times/omit-dir-times/omit-link-times), sparse/device/special preservation, copy-links/copy-dirlinks, explicit parent directory creation via `--mkpath`, and ACL/xattr preservation (enabled by default but still gated behind build features for downstream portability). It performs deterministic local copies for regular files, directories, symbolic links, hard links, device nodes, FIFOs, and sparse files while preserving permissions, timestamps, optional ownership metadata, and (when the default `acl`/`xattr` features are enabled) extended attributes and POSIX ACLs; streams progress and stats; and lists `rsync://` modules via the legacy handshake while forwarding `--port` overrides through the module query path. Batch workflows (`--write-batch`, `--only-write-batch`, `--read-batch`) force delegation through the configured fallback binary even for local operands so upstream rsync handles both batch creation and replay until the native engine lands. Remote operands spawn the system `rsync` (`OC_RSYNC_FALLBACK`) until the native delta-transfer engine and full filter/compression parity land, and the CLI now emits an actionable diagnostic when the fallback binary is missing or not executable so operators can install upstream rsync or point `OC_RSYNC_FALLBACK` at a custom path before retrying. The `rsync` compatibility wrapper shares the same execution path so legacy packaging layouts continue to function. | `crates/cli`, `src/bin/rsync.rs`, `src/bin/oc-rsync.rs`, `crates/core/src/client/mod.rs` | | Transport | Binary negotiation orchestration | Implemented | `binary::negotiate_binary_session` drives the remote-shell handshake, clamps the negotiated protocol, and returns the replaying stream together with the peer advertisement. | `crates/transport/src/binary.rs` | | Transport | Unified session handshake facade | Implemented | `session::negotiate_session` routes to binary or legacy handshakes, reports negotiated/clamped protocol metadata, and rehydrates sniffers so callers can resume without replaying the transport. | `crates/transport/src/session/handshake.rs` | | Workspace | Daemon server (`oc-rsync --daemon` with `oc-rsyncd`/`rsyncd` wrappers) | Partial | Launching `oc-rsync --daemon` listens on a configurable TCP socket with explicit IPv4/IPv6 selection via `--ipv4`/`--ipv6`, completes the legacy handshake for sequential connections, advertises active features via `@RSYNCD: CAP …` lines (currently `modules` and `authlist`), serves `#list` requests using modules provided via `--module` or `--config` (subset of `rsyncd.conf`), emits configurable MOTD lines from `--motd-file`/`--motd-line` and global `motd`/`motd file` directives, enforces `hosts allow`/`hosts deny`, validates `auth users` credentials against the configured secrets file using the upstream challenge/response exchange, honours module-level `use chroot` directives (rejecting non-absolute paths when enabled), caps simultaneous connections per module via the `max connections` directive, recognises runtime `--bwlimit`/`--no-bwlimit` toggles, and delegates authenticated module sessions to the system `rsync` binary by default (unless disabled via the `OC_RSYNC_*` overrides) while the native engine is completed. When built with `--features sd-notify`, the daemon emits `READY=1`, `STATUS=…`, and `STOPPING=1` notifications so the packaged systemd unit can track lifecycle events. The branded (`oc-rsyncd`) and legacy (`rsyncd`) wrappers reuse the same execution path for compatibility. | `crates/daemon/src/lib.rs`, `src/bin/rsyncd.rs`, `src/bin/oc-rsyncd.rs`, `src/bin/oc-rsync.rs` |