Skip to main content

remote/protocol/
mod.rs

1//! Remote copy protocol definitions for source-destination communication.
2//!
3//! # Protocol Overview
4//!
5//! The remote copy protocol uses TCP for communication between source and destination.
6//! The source listens on two ports: a control port for bidirectional messages and a
7//! data port for file transfers. Both sides exchange messages to coordinate directory
8//! creation, file transfers, and completion.
9//!
10//! See `docs/remote_protocol.md` for the full protocol specification.
11//!
12//! # Message Flow
13//!
14//! ```text
15//! Source                              Destination
16//!   |                                      |
17//!   |  ---- Directory(root, meta) -------> |  Create root, store metadata
18//!   |  ---- Directory(child, meta) ------> |  Create child, store metadata
19//!   |  ---- Symlink(...) ----------------> |  Create symlink
20//!   |  ---- DirStructureComplete --------> |  Structure complete
21//!   |                                      |
22//!   |  <--- DirectoryManifestChunk(root,..)|  0+ manifest chunks for reused dirs
23//!   |                                      |  under --overwrite/--ignore-existing,
24//!   |                                      |  sent BEFORE the trigger (FIFO)
25//!   |  <--- DirectoryCreated(root) -------- |  Pass-2 trigger
26//!   |  <--- DirectoryCreated(child) ------- |
27//!   |                                      |
28//!   |  ~~~~ File(f) ~~~~~~~~~~~~~~~~~~~~~> |  Write file (not in manifest / differs)
29//!   |  ---- FileUnchanged(g) -----------> |  identical g not transferred
30//!   |                                      |  All files done → apply metadata
31//!   |                                      |
32//!   |  <--- DestinationDone -------------- |  Close send side
33//!   |  (close send side)                   |  (detect EOF)
34//!   |  (detect EOF)                        |  Close connection
35//! ```
36//!
37//! # Error Communication
38//!
39//! The protocol uses asymmetric error communication:
40//! - **Source → Destination**: Must communicate failures (`FileSkipped`, `SymlinkSkipped`)
41//!   so destination can track file counts correctly. `FileUnchanged` is also sent Source →
42//!   Destination but is an optimization notification (not a failure): it signals the source
43//!   skipped a file whose destination copy is already identical, and is counted as
44//!   `files_unchanged` on the destination.
45//! - **Destination → Source**: Does NOT communicate failures. Destination handles
46//!   errors locally and source continues sending the full structure.
47//!
48//! # Shutdown Sequence
49//!
50//! Shutdown is coordinated through TCP connection closure:
51//! 1. Destination sends `DestinationDone` and closes its send side
52//! 2. Source detects EOF on recv, closes its send side
53//! 3. Destination detects EOF on recv, closes connection
54
55use serde::{Deserialize, Serialize};
56use std::os::unix::fs::MetadataExt;
57use std::os::unix::prelude::PermissionsExt;
58
59/// Default cap on the number of pre-existing destination entries the destination will put in a
60/// directory's overwrite/ignore-existing manifest. Above this, the manifest is omitted and that
61/// directory falls back to transferring-and-draining files (see `docs/remote_protocol.md`). High
62/// by default — rcp typically runs on large hosts; the cap is a backstop, not a normal limit.
63pub const DEFAULT_OVERWRITE_MANIFEST_MAX_ENTRIES: usize = 5_000_000;
64
65#[derive(Clone, Debug, Deserialize, Serialize)]
66pub struct Metadata {
67    pub mode: u32,
68    pub uid: u32,
69    pub gid: u32,
70    pub atime: i64,
71    pub mtime: i64,
72    pub atime_nsec: i64,
73    pub mtime_nsec: i64,
74}
75
76impl common::preserve::Metadata for Metadata {
77    fn uid(&self) -> u32 {
78        self.uid
79    }
80    fn gid(&self) -> u32 {
81        self.gid
82    }
83    fn atime(&self) -> i64 {
84        self.atime
85    }
86    fn atime_nsec(&self) -> i64 {
87        self.atime_nsec
88    }
89    fn mtime(&self) -> i64 {
90        self.mtime
91    }
92    fn mtime_nsec(&self) -> i64 {
93        self.mtime_nsec
94    }
95    fn permissions(&self) -> std::fs::Permissions {
96        std::fs::Permissions::from_mode(self.mode)
97    }
98}
99
100impl common::preserve::Metadata for &Metadata {
101    fn uid(&self) -> u32 {
102        (*self).uid()
103    }
104    fn gid(&self) -> u32 {
105        (*self).gid()
106    }
107    fn atime(&self) -> i64 {
108        (*self).atime()
109    }
110    fn atime_nsec(&self) -> i64 {
111        (*self).atime_nsec()
112    }
113    fn mtime(&self) -> i64 {
114        (*self).mtime()
115    }
116    fn mtime_nsec(&self) -> i64 {
117        (*self).mtime_nsec()
118    }
119    fn permissions(&self) -> std::fs::Permissions {
120        (*self).permissions()
121    }
122}
123
124impl From<&std::fs::Metadata> for Metadata {
125    fn from(metadata: &std::fs::Metadata) -> Self {
126        Metadata {
127            mode: metadata.mode(),
128            uid: metadata.uid(),
129            gid: metadata.gid(),
130            atime: metadata.atime(),
131            mtime: metadata.mtime(),
132            atime_nsec: metadata.atime_nsec(),
133            mtime_nsec: metadata.mtime_nsec(),
134        }
135    }
136}
137
138impl From<&common::safedir::FileMeta> for Metadata {
139    /// Build a wire `Metadata` from an fd-pinned [`common::safedir::FileMeta`]
140    /// snapshot (obtained via `fstat`/`fstatat` during a TOCTOU-safe walk),
141    /// reading every field through the shared `preserve::Metadata` trait so it
142    /// stays in lock-step with the `&std::fs::Metadata` conversion above.
143    fn from(meta: &common::safedir::FileMeta) -> Self {
144        use common::preserve::Metadata as _;
145        Metadata {
146            mode: meta.permissions().mode(),
147            uid: meta.uid(),
148            gid: meta.gid(),
149            atime: meta.atime(),
150            mtime: meta.mtime(),
151            atime_nsec: meta.atime_nsec(),
152            mtime_nsec: meta.mtime_nsec(),
153        }
154    }
155}
156
157/// One pre-existing destination directory entry, sent in a `DirectoryManifestChunk` so the
158/// source can skip transferring identical files. `name` is the child name (serialized as a
159/// `PathBuf`, matching the rest of the protocol's path handling). `metadata`/`size` are only
160/// meaningful when `is_file`.
161#[derive(Clone, Debug, Deserialize, Serialize)]
162pub struct ExistingEntry {
163    pub name: std::path::PathBuf,
164    pub is_file: bool,
165    pub metadata: Metadata,
166    pub size: u64,
167}
168
169/// Conservative byte budget for a single `DirectoryManifestChunk`. Well under the control
170/// stream's 8 MiB `LengthDelimitedCodec` frame limit, leaving ample margin for the message
171/// envelope and the worst-case final entry (a single ~`PATH_MAX` name).
172pub const MANIFEST_CHUNK_BYTE_BUDGET: usize = 4 * 1024 * 1024;
173
174/// Conservative serialized-size estimate for one `ExistingEntry`: the variable-length name plus a
175/// fixed allowance covering `is_file` + `Metadata` + `size` + per-entry framing overhead. Used
176/// only to bound chunk sizes, so over-estimating (smaller, safer chunks) is fine.
177fn estimate_entry_size(entry: &ExistingEntry) -> usize {
178    entry.name.as_os_str().len() + 64
179}
180
181/// Split a directory manifest into chunks each estimated to stay within `byte_budget`, so every
182/// `DirectoryManifestChunk` frame fits under the control stream's frame limit. A single entry
183/// larger than the budget still gets its own chunk (it cannot be split further); with the 4 MiB
184/// budget versus a worst-case ~4 KiB path, a chunk never approaches the 8 MiB frame limit.
185/// `chunk_manifest(v, b).concat() == v` for any `v` (no entries lost or reordered).
186pub fn chunk_manifest(entries: Vec<ExistingEntry>, byte_budget: usize) -> Vec<Vec<ExistingEntry>> {
187    let mut chunks: Vec<Vec<ExistingEntry>> = Vec::new();
188    let mut current: Vec<ExistingEntry> = Vec::new();
189    let mut current_bytes = 0usize;
190    for entry in entries {
191        let size = estimate_entry_size(&entry);
192        if !current.is_empty() && current_bytes + size > byte_budget {
193            chunks.push(std::mem::take(&mut current));
194            current_bytes = 0;
195        }
196        current_bytes += size;
197        current.push(entry);
198    }
199    if !current.is_empty() {
200        chunks.push(current);
201    }
202    chunks
203}
204
205/// File header sent on unidirectional streams, followed by raw file data.
206#[derive(Debug, Deserialize, Serialize)]
207pub struct File {
208    pub src: std::path::PathBuf,
209    pub dst: std::path::PathBuf,
210    pub size: u64,
211    pub metadata: Metadata,
212    pub is_root: bool,
213}
214
215/// Wrapper that includes size for comparison purposes.
216#[derive(Debug)]
217pub struct FileMetadata<'a> {
218    pub metadata: &'a Metadata,
219    pub size: u64,
220}
221
222impl<'a> common::preserve::Metadata for FileMetadata<'a> {
223    fn uid(&self) -> u32 {
224        self.metadata.uid()
225    }
226    fn gid(&self) -> u32 {
227        self.metadata.gid()
228    }
229    fn atime(&self) -> i64 {
230        self.metadata.atime()
231    }
232    fn atime_nsec(&self) -> i64 {
233        self.metadata.atime_nsec()
234    }
235    fn mtime(&self) -> i64 {
236        self.metadata.mtime()
237    }
238    fn mtime_nsec(&self) -> i64 {
239        self.metadata.mtime_nsec()
240    }
241    fn permissions(&self) -> std::fs::Permissions {
242        self.metadata.permissions()
243    }
244    fn size(&self) -> u64 {
245        self.size
246    }
247}
248
249/// Messages sent from source to destination on the control stream.
250#[derive(Debug, Deserialize, Serialize)]
251pub enum SourceMessage {
252    /// Create directory, store metadata, and declare entry counts for completion tracking.
253    /// Sent during directory tree traversal in depth-first order. Source pre-reads the
254    /// directory children before sending, so counts are known at send time.
255    Directory {
256        src: std::path::PathBuf,
257        dst: std::path::PathBuf,
258        metadata: Metadata,
259        is_root: bool,
260        /// total child entries (files + directories + symlinks) for completion tracking
261        entry_count: usize,
262        /// whether to keep this directory if it ends up empty after filtering
263        keep_if_empty: bool,
264    },
265    /// Create symlink with metadata.
266    Symlink {
267        src: std::path::PathBuf,
268        dst: std::path::PathBuf,
269        target: std::path::PathBuf,
270        metadata: Metadata,
271        is_root: bool,
272    },
273    /// Signal that all directories and symlinks have been sent.
274    /// Required before destination can send `DestinationDone`.
275    /// `has_root_item` indicates whether a root file/directory/symlink will be sent.
276    /// When false (dry-run or filtered root), destination can mark root as complete.
277    DirStructureComplete { has_root_item: bool },
278    /// Notify destination that a file failed to send.
279    /// Counts as a processed entry for the parent directory's completion tracking.
280    FileSkipped {
281        src: std::path::PathBuf,
282        dst: std::path::PathBuf,
283    },
284    /// Notify destination that the source skipped sending a file because the destination
285    /// already holds a matching entry (per the directory manifest). Counts as a processed
286    /// entry for the parent directory and as `files_unchanged` (the destination is the
287    /// authority for that count). The control-stream sibling of `FileSkipped`.
288    FileUnchanged {
289        src: std::path::PathBuf,
290        dst: std::path::PathBuf,
291    },
292    /// Notify destination that a symlink failed to read.
293    /// If `is_root` is true, this signals that root processing is complete (even if failed).
294    /// Non-root skipped symlinks count as a processed entry for the parent directory.
295    SymlinkSkipped { src_dst: SrcDst, is_root: bool },
296}
297
298#[derive(Clone, Debug, Deserialize, Serialize)]
299pub struct SrcDst {
300    pub src: std::path::PathBuf,
301    pub dst: std::path::PathBuf,
302}
303
304/// Messages sent from destination to source on the control stream.
305#[derive(Clone, Debug, Deserialize, Serialize)]
306pub enum DestinationMessage {
307    /// Carry a chunk of the (reused) destination directory's pre-existing-entry manifest, used
308    /// by the source to skip transferring identical files. A directory's manifest is split into
309    /// one or more chunks (each well under the control stream's frame limit) and ALL of them are
310    /// sent BEFORE the directory's `DirectoryCreated`; the control stream is FIFO, so the source
311    /// has the complete manifest by the time it sees `DirectoryCreated`. No chunks are sent for a
312    /// freshly-created directory, when neither `--overwrite` nor `--ignore-existing` is active, or
313    /// when the directory exceeds the manifest cap (see `RcpdConfig::overwrite_manifest_max_entries`).
314    DirectoryManifestChunk {
315        dst: std::path::PathBuf,
316        entries: Vec<ExistingEntry>,
317    },
318    /// Confirm directory created, request file transfers. This is purely the
319    /// Pass-2 trigger: it tells the source the destination created the directory
320    /// and is ready to receive its files. The source already retains the
321    /// authoritative Pass-1 file count for the directory (in its fd-map entry under
322    /// hardened reads, or in a path→count map under `-L`), so no count is echoed
323    /// back here. Any `DirectoryManifestChunk`s for this directory precede this message.
324    DirectoryCreated {
325        src: std::path::PathBuf,
326        dst: std::path::PathBuf,
327    },
328    /// Acknowledge a `Directory` message the destination did NOT create (create
329    /// failed, ancestor failed, or `--ignore-existing` skipped a non-directory).
330    /// No files will be requested for it. The destination sends exactly one of
331    /// `DirectoryCreated` / `DirectorySkipped` per `Directory` message so the
332    /// source can release the matching held directory fd (see the source-side
333    /// fd-map / dir-fd budget in `rcp::source`): without this nack a skipped
334    /// directory's Pass-1 permit would never be released, hanging large no-ack
335    /// subtrees. `src` keys the source-side fd-map entry to release (the map is
336    /// inserted under `src`; see `take_for_skipped`); `dst` is carried for
337    /// symmetry/logging.
338    DirectorySkipped {
339        src: std::path::PathBuf,
340        dst: std::path::PathBuf,
341    },
342    /// Signal destination has finished all operations.
343    /// Initiates graceful shutdown via stream closure.
344    DestinationDone,
345}
346
347#[derive(Clone, Debug, Deserialize, Serialize)]
348pub struct RcpdConfig {
349    pub verbose: u8,
350    pub fail_early: bool,
351    pub max_workers: usize,
352    pub max_blocking_threads: usize,
353    pub max_open_files: Option<usize>,
354    pub ops_throttle: usize,
355    pub iops_throttle: usize,
356    pub chunk_size: usize,
357    /// Adaptive metadata-ops throttle settings, propagated from the
358    /// master's `--auto-meta-*` flags. `None` means the feature is off on
359    /// this rcpd instance.
360    pub auto_meta: Option<common::AutoMetaThrottleConfig>,
361    /// Mirror of master's --auto-meta-histogram flag.
362    pub auto_meta_histogram: bool,
363    /// Mirror of master's --auto-meta-histogram-log path. Each rcpd
364    /// suffixes its own trace identifier so the master and rcpds don't
365    /// collide on a localhost run.
366    pub auto_meta_histogram_log: Option<String>,
367    /// Mirror of master's --auto-meta-histogram-interval.
368    pub auto_meta_histogram_interval: std::time::Duration,
369    // common::copy::Settings
370    pub dereference: bool,
371    /// Mirror of master's --require-toctou-safe flag: arms strict operand
372    /// resolution (openat2 RESOLVE_NO_SYMLINKS root opens) on the rcpd side.
373    pub require_toctou_safe: bool,
374    pub overwrite: bool,
375    pub overwrite_compare: String,
376    /// Cap on pre-existing entries put into a directory's overwrite/ignore-existing manifest.
377    pub overwrite_manifest_max_entries: usize,
378    pub overwrite_filter: Option<String>,
379    pub ignore_existing: bool,
380    pub skip_specials: bool,
381    pub debug_log_prefix: Option<String>,
382    /// Port ranges for TCP connections (e.g., "8000-8999,9000-9999")
383    pub port_ranges: Option<String>,
384    pub progress: bool,
385    pub progress_delay: Option<String>,
386    pub remote_copy_conn_timeout_sec: u64,
387    /// Network profile for buffer sizing
388    pub network_profile: crate::NetworkProfile,
389    /// Buffer size for file transfers (defaults to profile-specific value)
390    pub buffer_size: Option<usize>,
391    /// Maximum concurrent connections in the pool
392    pub max_connections: usize,
393    /// Multiplier for pending file writes (max pending = max_connections × multiplier)
394    pub pending_writes_multiplier: usize,
395    /// Chrome trace output prefix for profiling
396    pub chrome_trace_prefix: Option<String>,
397    /// Flamegraph output prefix for profiling
398    pub flamegraph_prefix: Option<String>,
399    /// Log level for profiling (default: trace when profiling is enabled)
400    pub profile_level: Option<String>,
401    /// Enable tokio-console
402    pub tokio_console: bool,
403    /// Port for tokio-console server
404    pub tokio_console_port: Option<u16>,
405    /// Enable TLS encryption (default: true)
406    pub encryption: bool,
407    /// Master's certificate fingerprint for client authentication (when encryption enabled)
408    pub master_cert_fingerprint: Option<CertFingerprint>,
409}
410
411impl RcpdConfig {
412    pub fn to_args(&self) -> Vec<String> {
413        let mut args = vec![
414            format!("--max-workers={}", self.max_workers),
415            format!("--max-blocking-threads={}", self.max_blocking_threads),
416            format!("--ops-throttle={}", self.ops_throttle),
417            format!("--iops-throttle={}", self.iops_throttle),
418            format!("--chunk-size={}", self.chunk_size),
419            format!("--overwrite-compare={}", self.overwrite_compare),
420            format!(
421                "--overwrite-manifest-max-entries={}",
422                self.overwrite_manifest_max_entries
423            ),
424        ];
425        if self.verbose > 0 {
426            args.push(format!("-{}", "v".repeat(self.verbose as usize)));
427        }
428        if self.fail_early {
429            args.push("--fail-early".to_string());
430        }
431        if let Some(v) = self.max_open_files {
432            args.push(format!("--max-open-files={v}"));
433        }
434        if self.dereference {
435            args.push("--dereference".to_string());
436        }
437        if self.require_toctou_safe {
438            args.push("--require-toctou-safe".to_string());
439        }
440        if self.overwrite {
441            args.push("--overwrite".to_string());
442            if let Some(ref filter) = self.overwrite_filter {
443                args.push(format!("--overwrite-filter={filter}"));
444            }
445        }
446        if self.ignore_existing {
447            args.push("--ignore-existing".to_string());
448        }
449        if self.skip_specials {
450            args.push("--skip-specials".to_string());
451        }
452        if let Some(ref prefix) = self.debug_log_prefix {
453            args.push(format!("--debug-log-prefix={prefix}"));
454        }
455        if let Some(ref ranges) = self.port_ranges {
456            args.push(format!("--port-ranges={ranges}"));
457        }
458        if self.progress {
459            args.push("--progress".to_string());
460        }
461        if let Some(ref delay) = self.progress_delay {
462            args.push(format!("--progress-delay={delay}"));
463        }
464        args.push(format!(
465            "--remote-copy-conn-timeout-sec={}",
466            self.remote_copy_conn_timeout_sec
467        ));
468        // network profile
469        args.push(format!("--network-profile={}", self.network_profile));
470        // tcp tuning (only if set)
471        if let Some(v) = self.buffer_size {
472            args.push(format!("--buffer-size={v}"));
473        }
474        args.push(format!("--max-connections={}", self.max_connections));
475        args.push(format!(
476            "--pending-writes-multiplier={}",
477            self.pending_writes_multiplier
478        ));
479        // profiling options (only add --profile-level when profiling is enabled)
480        let profiling_enabled =
481            self.chrome_trace_prefix.is_some() || self.flamegraph_prefix.is_some();
482        if let Some(ref prefix) = self.chrome_trace_prefix {
483            args.push(format!("--chrome-trace={prefix}"));
484        }
485        if let Some(ref prefix) = self.flamegraph_prefix {
486            args.push(format!("--flamegraph={prefix}"));
487        }
488        if profiling_enabled && let Some(level) = &self.profile_level {
489            args.push(format!("--profile-level={level}"));
490        }
491        if self.tokio_console {
492            args.push("--tokio-console".to_string());
493        }
494        if let Some(port) = self.tokio_console_port {
495            args.push(format!("--tokio-console-port={port}"));
496        }
497        if !self.encryption {
498            args.push("--no-encryption".to_string());
499        }
500        if let Some(fp) = self.master_cert_fingerprint {
501            args.push(format!(
502                "--master-cert-fp={}",
503                crate::tls::fingerprint_to_hex(&fp)
504            ));
505        }
506        // propagate the adaptive metadata-ops throttle settings to rcpd so a
507        // remote copy uses the same control law as the master-side tool.
508        if let Some(auto) = &self.auto_meta {
509            args.push("--auto-meta-throttle".to_string());
510            args.push(format!("--auto-meta-initial-cwnd={}", auto.initial_cwnd));
511            args.push(format!("--auto-meta-min-cwnd={}", auto.min_cwnd));
512            args.push(format!("--auto-meta-max-cwnd={}", auto.max_cwnd));
513            args.push(format!("--auto-meta-alpha={}", auto.alpha));
514            args.push(format!("--auto-meta-beta={}", auto.beta));
515            args.push(format!(
516                "--auto-meta-baseline-percentile={}",
517                auto.baseline_percentile,
518            ));
519            args.push(format!(
520                "--auto-meta-current-percentile={}",
521                auto.current_percentile,
522            ));
523            args.push(format!("--auto-meta-increase-step={}", auto.increase_step));
524            args.push(format!("--auto-meta-decrease-step={}", auto.decrease_step));
525            args.push(format!(
526                "--auto-meta-long-window={}",
527                humantime::format_duration(auto.long_window),
528            ));
529            args.push(format!(
530                "--auto-meta-short-window={}",
531                humantime::format_duration(auto.short_window),
532            ));
533            args.push(format!(
534                "--auto-meta-tick-interval={}",
535                humantime::format_duration(auto.tick_interval),
536            ));
537        }
538        // Only forward histogram flags when there's a log path: the panel-
539        // only flag (--auto-meta-histogram) makes rcpd pay the synchronous
540        // accumulator lock cost on every probe, but rcpd's panel never
541        // reaches the user (the master's remote-progress renderer doesn't
542        // read the rcpd histogram registry). The log path is different —
543        // it produces a concrete artifact on the rcpd's host that the user
544        // can collect after the run.
545        if let Some(path) = &self.auto_meta_histogram_log {
546            args.push(format!("--auto-meta-histogram-log={path}"));
547            args.push(format!(
548                "--auto-meta-histogram-interval={}",
549                humantime::format_duration(self.auto_meta_histogram_interval),
550            ));
551        }
552        args
553    }
554}
555
556#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
557pub enum RcpdRole {
558    Source,
559    Destination,
560}
561
562impl std::fmt::Display for RcpdRole {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        match self {
565            RcpdRole::Source => write!(f, "source"),
566            RcpdRole::Destination => write!(f, "destination"),
567        }
568    }
569}
570
571impl std::str::FromStr for RcpdRole {
572    type Err = anyhow::Error;
573    fn from_str(s: &str) -> Result<Self, Self::Err> {
574        match s.to_lowercase().as_str() {
575            "source" => Ok(RcpdRole::Source),
576            "destination" | "dest" => Ok(RcpdRole::Destination),
577            _ => Err(anyhow::anyhow!("invalid role: {}", s)),
578        }
579    }
580}
581
582#[derive(Clone, Debug, Deserialize, Serialize)]
583pub struct TracingHello {
584    pub role: RcpdRole,
585    /// true for tracing/progress connection, false for control connection
586    pub is_tracing: bool,
587}
588
589/// TLS certificate fingerprint (SHA-256 of DER-encoded certificate).
590pub type CertFingerprint = [u8; 32];
591
592#[derive(Clone, Debug, Deserialize, Serialize)]
593pub enum MasterHello {
594    Source {
595        src: std::path::PathBuf,
596        dst: std::path::PathBuf,
597        /// Destination's TLS certificate fingerprint (None if encryption disabled)
598        dest_cert_fingerprint: Option<CertFingerprint>,
599        /// Filter settings for include/exclude patterns (source-side filtering)
600        filter: Option<common::filter::FilterSettings>,
601        /// Dry-run mode for previewing operations
602        dry_run: Option<common::config::DryRunMode>,
603    },
604    Destination {
605        /// TCP address for control connection to source
606        source_control_addr: std::net::SocketAddr,
607        /// TCP address for data connections to source
608        source_data_addr: std::net::SocketAddr,
609        server_name: String,
610        preserve: common::preserve::Settings,
611        /// Source's TLS certificate fingerprint (None if encryption disabled)
612        source_cert_fingerprint: Option<CertFingerprint>,
613    },
614}
615
616#[derive(Clone, Debug, Deserialize, Serialize)]
617pub struct SourceMasterHello {
618    /// TCP address for control connection (bidirectional messages)
619    pub control_addr: std::net::SocketAddr,
620    /// TCP address for data connections (file transfers)
621    pub data_addr: std::net::SocketAddr,
622    pub server_name: String,
623}
624
625// re-export RuntimeStats from common for convenience
626pub use common::RuntimeStats;
627
628#[derive(Clone, Debug, Deserialize, Serialize)]
629pub enum RcpdResult {
630    Success {
631        message: String,
632        summary: common::copy::Summary,
633        runtime_stats: common::RuntimeStats,
634    },
635    Failure {
636        error: String,
637        summary: common::copy::Summary,
638        runtime_stats: common::RuntimeStats,
639    },
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    fn mk_entry(name: &str) -> ExistingEntry {
647        ExistingEntry {
648            name: std::path::PathBuf::from(name),
649            is_file: true,
650            metadata: Metadata {
651                mode: 0o644,
652                uid: 0,
653                gid: 0,
654                atime: 0,
655                mtime: 0,
656                atime_nsec: 0,
657                mtime_nsec: 0,
658            },
659            size: 0,
660        }
661    }
662
663    #[test]
664    fn chunk_manifest_empty_yields_no_chunks() {
665        assert!(chunk_manifest(vec![], MANIFEST_CHUNK_BYTE_BUDGET).is_empty());
666    }
667
668    #[test]
669    fn chunk_manifest_small_is_single_chunk() {
670        let entries: Vec<_> = (0..100).map(|i| mk_entry(&format!("f{i}.txt"))).collect();
671        let chunks = chunk_manifest(entries, MANIFEST_CHUNK_BYTE_BUDGET);
672        assert_eq!(chunks.len(), 1);
673        assert_eq!(chunks[0].len(), 100);
674    }
675
676    #[test]
677    fn chunk_manifest_splits_and_preserves_all_entries_in_order() {
678        let entries: Vec<_> = (0..1000)
679            .map(|i| mk_entry(&format!("file_{i:04}.dat")))
680            .collect();
681        // a tiny budget forces many chunks (each entry estimate is name.len() + 64 ≈ ~78 bytes)
682        let chunks = chunk_manifest(entries.clone(), 256);
683        assert!(
684            chunks.len() > 1,
685            "tiny budget should produce multiple chunks"
686        );
687        // frame-safety property: every multi-entry chunk stays within budget, so a chunk frame
688        // never approaches the control stream's frame limit
689        for chunk in &chunks {
690            if chunk.len() > 1 {
691                let total: usize = chunk.iter().map(estimate_entry_size).sum();
692                assert!(total <= 256, "multi-entry chunk exceeds budget: {total}");
693            }
694        }
695        // reassembly invariant: concat preserves every entry, in order, with nothing lost
696        let flat: Vec<_> = chunks.into_iter().flatten().collect();
697        assert_eq!(flat.len(), entries.len());
698        for (got, want) in flat.iter().zip(entries.iter()) {
699            assert_eq!(got.name, want.name);
700        }
701    }
702
703    #[test]
704    fn chunk_manifest_entry_larger_than_budget_gets_its_own_chunk() {
705        // budget smaller than a single entry: each still lands in its own chunk (never dropped).
706        let chunks = chunk_manifest(vec![mk_entry("a"), mk_entry("b")], 1);
707        assert_eq!(chunks.len(), 2);
708        assert_eq!(chunks[0].len(), 1);
709        assert_eq!(chunks[1].len(), 1);
710    }
711
712    #[test]
713    fn chunk_manifest_splits_a_large_manifest_at_the_production_budget() {
714        // a realistically large directory manifest (more than two budgets' worth) must split into
715        // multiple frames with the PRODUCTION budget — not just an artificially tiny one — so a
716        // change to the budget or to `estimate_entry_size` that breaks chunking at real scale is
717        // caught. built in memory (no files), so it stays a fast unit test.
718        let per_entry = estimate_entry_size(&mk_entry("file_0000000.dat"));
719        let n = (MANIFEST_CHUNK_BYTE_BUDGET / per_entry) * 2 + 1000;
720        let entries: Vec<_> = (0..n)
721            .map(|i| mk_entry(&format!("file_{i:07}.dat")))
722            .collect();
723        let chunks = chunk_manifest(entries.clone(), MANIFEST_CHUNK_BYTE_BUDGET);
724        assert!(
725            chunks.len() > 1,
726            "a manifest larger than the 4 MiB budget must span multiple chunks, got {}",
727            chunks.len()
728        );
729        // every multi-entry chunk stays within the real budget (frame-safety)
730        for chunk in &chunks {
731            if chunk.len() > 1 {
732                let total: usize = chunk.iter().map(estimate_entry_size).sum();
733                assert!(
734                    total <= MANIFEST_CHUNK_BYTE_BUDGET,
735                    "a chunk exceeds the production budget: {total}"
736                );
737            }
738        }
739        // reassembly invariant at production scale: concat preserves every entry, in order
740        let flat: Vec<_> = chunks.into_iter().flatten().collect();
741        assert_eq!(
742            flat.len(),
743            entries.len(),
744            "reassembly must preserve every entry"
745        );
746        assert!(
747            flat.iter().zip(&entries).all(|(a, b)| a.name == b.name),
748            "reassembly must preserve order"
749        );
750    }
751
752    fn minimal_rcpd_config() -> RcpdConfig {
753        RcpdConfig {
754            verbose: 0,
755            fail_early: false,
756            max_workers: 0,
757            max_blocking_threads: 0,
758            max_open_files: None,
759            ops_throttle: 0,
760            iops_throttle: 0,
761            chunk_size: 0,
762            auto_meta: None,
763            auto_meta_histogram: false,
764            auto_meta_histogram_log: None,
765            auto_meta_histogram_interval: std::time::Duration::from_secs(1),
766            dereference: false,
767            require_toctou_safe: false,
768            overwrite: false,
769            overwrite_compare: "size,mtime".to_string(),
770            overwrite_filter: None,
771            ignore_existing: false,
772            skip_specials: false,
773            debug_log_prefix: None,
774            port_ranges: None,
775            progress: false,
776            progress_delay: None,
777            remote_copy_conn_timeout_sec: 30,
778            network_profile: crate::NetworkProfile::default(),
779            buffer_size: None,
780            max_connections: 1,
781            pending_writes_multiplier: 1,
782            chrome_trace_prefix: None,
783            flamegraph_prefix: None,
784            profile_level: None,
785            tokio_console: false,
786            tokio_console_port: None,
787            encryption: true,
788            master_cert_fingerprint: None,
789            overwrite_manifest_max_entries: DEFAULT_OVERWRITE_MANIFEST_MAX_ENTRIES,
790        }
791    }
792
793    #[test]
794    fn to_args_includes_overwrite_manifest_max_entries() {
795        let mut config = minimal_rcpd_config();
796        config.overwrite_manifest_max_entries = 123_456;
797        let args = config.to_args();
798        assert!(
799            args.iter()
800                .any(|a| a == "--overwrite-manifest-max-entries=123456"),
801            "expected manifest cap flag in {args:?}"
802        );
803    }
804
805    #[test]
806    fn to_args_mirrors_require_toctou_safe() {
807        let mut config = minimal_rcpd_config();
808        config.require_toctou_safe = true;
809        let args = config.to_args();
810        assert!(
811            args.iter().any(|a| a == "--require-toctou-safe"),
812            "expected --require-toctou-safe in {args:?}"
813        );
814        let config = minimal_rcpd_config();
815        assert!(
816            !config
817                .to_args()
818                .iter()
819                .any(|a| a == "--require-toctou-safe"),
820            "flag must be omitted when off"
821        );
822    }
823
824    #[test]
825    fn to_args_omits_auto_meta_throttle_when_none() {
826        let args = minimal_rcpd_config().to_args();
827        // throttle-specific flags must be absent when auto_meta is None
828        let throttle_flags = [
829            "--auto-meta-throttle",
830            "--auto-meta-initial-cwnd",
831            "--auto-meta-min-cwnd",
832            "--auto-meta-max-cwnd",
833            "--auto-meta-alpha",
834            "--auto-meta-beta",
835            "--auto-meta-baseline-percentile",
836            "--auto-meta-current-percentile",
837            "--auto-meta-increase-step",
838            "--auto-meta-decrease-step",
839            "--auto-meta-long-window",
840            "--auto-meta-short-window",
841            "--auto-meta-tick-interval",
842        ];
843        for flag in throttle_flags {
844            assert!(
845                !args.iter().any(|a| a.starts_with(flag)),
846                "throttle flag {flag} should not be emitted when auto_meta is None: {args:?}",
847            );
848        }
849        // histogram flag, log, and interval must all be absent when histograms are off
850        for arg in &args {
851            assert!(
852                !arg.starts_with("--auto-meta-histogram"),
853                "must not emit any histogram flag when histograms are off, found: {arg}",
854            );
855        }
856    }
857
858    #[test]
859    fn to_args_propagates_all_auto_meta_fields() {
860        let mut config = minimal_rcpd_config();
861        config.auto_meta = Some(common::AutoMetaThrottleConfig {
862            initial_cwnd: 8,
863            min_cwnd: 2,
864            max_cwnd: 128,
865            alpha: 1.2,
866            beta: 1.6,
867            increase_step: 2,
868            decrease_step: 3,
869            baseline_percentile: 0.4,
870            current_percentile: 0.6,
871            long_window: std::time::Duration::from_secs(20),
872            short_window: std::time::Duration::from_secs(2),
873            tick_interval: std::time::Duration::from_millis(75),
874        });
875        let args = config.to_args();
876        let has = |needle: &str| args.iter().any(|a| a == needle);
877        let has_prefix = |needle: &str| args.iter().any(|a| a.starts_with(needle));
878        assert!(has("--auto-meta-throttle"));
879        assert!(has("--auto-meta-initial-cwnd=8"));
880        assert!(has("--auto-meta-min-cwnd=2"));
881        assert!(has("--auto-meta-max-cwnd=128"));
882        assert!(has_prefix("--auto-meta-alpha=1.2"));
883        assert!(has_prefix("--auto-meta-beta=1.6"));
884        assert!(has_prefix("--auto-meta-baseline-percentile=0.4"));
885        assert!(has_prefix("--auto-meta-current-percentile=0.6"));
886        assert!(has("--auto-meta-increase-step=2"));
887        assert!(has("--auto-meta-decrease-step=3"));
888        assert!(has_prefix("--auto-meta-long-window="));
889        assert!(has_prefix("--auto-meta-short-window="));
890        assert!(has_prefix("--auto-meta-tick-interval="));
891    }
892
893    #[test]
894    fn to_args_omits_histogram_flags_when_disabled() {
895        // Critical for backward compatibility: existing rcpd binaries
896        // built without histogram support reject --auto-meta-histogram-*
897        // flags, so we must not emit them on every remote copy.
898        let mut config = minimal_rcpd_config();
899        config.auto_meta_histogram = false;
900        config.auto_meta_histogram_log = None;
901        let args = config.to_args();
902        for arg in &args {
903            assert!(
904                !arg.starts_with("--auto-meta-histogram"),
905                "must not emit histogram flag when disabled, found: {arg}",
906            );
907        }
908    }
909
910    #[test]
911    fn to_args_omits_panel_only_flag_when_no_log_path() {
912        // Panel-only --auto-meta-histogram is intentionally NOT forwarded
913        // to rcpd: the panel never reaches the user (no plumbing in remote
914        // progress), and forwarding would just add per-probe lock cost.
915        let mut config = minimal_rcpd_config();
916        config.auto_meta_histogram = true;
917        config.auto_meta_histogram_log = None;
918        let args = config.to_args();
919        for arg in &args {
920            assert!(
921                !arg.starts_with("--auto-meta-histogram"),
922                "panel-only flag must not be forwarded to rcpd, found: {arg}",
923            );
924        }
925    }
926
927    #[test]
928    fn to_args_forwards_histogram_log_and_interval_when_log_path_set() {
929        let mut config = minimal_rcpd_config();
930        config.auto_meta_histogram = false; // panel-only off
931        config.auto_meta_histogram_log = Some("/tmp/foo.hdr".into());
932        config.auto_meta_histogram_interval = std::time::Duration::from_millis(500);
933        let args = config.to_args();
934        assert!(
935            args.iter()
936                .any(|a| a == "--auto-meta-histogram-log=/tmp/foo.hdr")
937        );
938        assert!(
939            args.iter()
940                .any(|a| a.starts_with("--auto-meta-histogram-interval="))
941        );
942        // The bare panel flag is NOT pushed; the log flag at parse time on
943        // rcpd already implies the accumulator pipeline.
944        assert!(
945            !args.iter().any(|a| a == "--auto-meta-histogram"),
946            "panel-only flag must not be forwarded; the log flag implies the pipeline",
947        );
948    }
949}