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    pub overwrite: bool,
372    pub overwrite_compare: String,
373    /// Cap on pre-existing entries put into a directory's overwrite/ignore-existing manifest.
374    pub overwrite_manifest_max_entries: usize,
375    pub overwrite_filter: Option<String>,
376    pub ignore_existing: bool,
377    pub skip_specials: bool,
378    pub debug_log_prefix: Option<String>,
379    /// Port ranges for TCP connections (e.g., "8000-8999,9000-9999")
380    pub port_ranges: Option<String>,
381    pub progress: bool,
382    pub progress_delay: Option<String>,
383    pub remote_copy_conn_timeout_sec: u64,
384    /// Network profile for buffer sizing
385    pub network_profile: crate::NetworkProfile,
386    /// Buffer size for file transfers (defaults to profile-specific value)
387    pub buffer_size: Option<usize>,
388    /// Maximum concurrent connections in the pool
389    pub max_connections: usize,
390    /// Multiplier for pending file writes (max pending = max_connections × multiplier)
391    pub pending_writes_multiplier: usize,
392    /// Chrome trace output prefix for profiling
393    pub chrome_trace_prefix: Option<String>,
394    /// Flamegraph output prefix for profiling
395    pub flamegraph_prefix: Option<String>,
396    /// Log level for profiling (default: trace when profiling is enabled)
397    pub profile_level: Option<String>,
398    /// Enable tokio-console
399    pub tokio_console: bool,
400    /// Port for tokio-console server
401    pub tokio_console_port: Option<u16>,
402    /// Enable TLS encryption (default: true)
403    pub encryption: bool,
404    /// Master's certificate fingerprint for client authentication (when encryption enabled)
405    pub master_cert_fingerprint: Option<CertFingerprint>,
406}
407
408impl RcpdConfig {
409    pub fn to_args(&self) -> Vec<String> {
410        let mut args = vec![
411            format!("--max-workers={}", self.max_workers),
412            format!("--max-blocking-threads={}", self.max_blocking_threads),
413            format!("--ops-throttle={}", self.ops_throttle),
414            format!("--iops-throttle={}", self.iops_throttle),
415            format!("--chunk-size={}", self.chunk_size),
416            format!("--overwrite-compare={}", self.overwrite_compare),
417            format!(
418                "--overwrite-manifest-max-entries={}",
419                self.overwrite_manifest_max_entries
420            ),
421        ];
422        if self.verbose > 0 {
423            args.push(format!("-{}", "v".repeat(self.verbose as usize)));
424        }
425        if self.fail_early {
426            args.push("--fail-early".to_string());
427        }
428        if let Some(v) = self.max_open_files {
429            args.push(format!("--max-open-files={v}"));
430        }
431        if self.dereference {
432            args.push("--dereference".to_string());
433        }
434        if self.overwrite {
435            args.push("--overwrite".to_string());
436            if let Some(ref filter) = self.overwrite_filter {
437                args.push(format!("--overwrite-filter={filter}"));
438            }
439        }
440        if self.ignore_existing {
441            args.push("--ignore-existing".to_string());
442        }
443        if self.skip_specials {
444            args.push("--skip-specials".to_string());
445        }
446        if let Some(ref prefix) = self.debug_log_prefix {
447            args.push(format!("--debug-log-prefix={prefix}"));
448        }
449        if let Some(ref ranges) = self.port_ranges {
450            args.push(format!("--port-ranges={ranges}"));
451        }
452        if self.progress {
453            args.push("--progress".to_string());
454        }
455        if let Some(ref delay) = self.progress_delay {
456            args.push(format!("--progress-delay={delay}"));
457        }
458        args.push(format!(
459            "--remote-copy-conn-timeout-sec={}",
460            self.remote_copy_conn_timeout_sec
461        ));
462        // network profile
463        args.push(format!("--network-profile={}", self.network_profile));
464        // tcp tuning (only if set)
465        if let Some(v) = self.buffer_size {
466            args.push(format!("--buffer-size={v}"));
467        }
468        args.push(format!("--max-connections={}", self.max_connections));
469        args.push(format!(
470            "--pending-writes-multiplier={}",
471            self.pending_writes_multiplier
472        ));
473        // profiling options (only add --profile-level when profiling is enabled)
474        let profiling_enabled =
475            self.chrome_trace_prefix.is_some() || self.flamegraph_prefix.is_some();
476        if let Some(ref prefix) = self.chrome_trace_prefix {
477            args.push(format!("--chrome-trace={prefix}"));
478        }
479        if let Some(ref prefix) = self.flamegraph_prefix {
480            args.push(format!("--flamegraph={prefix}"));
481        }
482        if profiling_enabled && let Some(level) = &self.profile_level {
483            args.push(format!("--profile-level={level}"));
484        }
485        if self.tokio_console {
486            args.push("--tokio-console".to_string());
487        }
488        if let Some(port) = self.tokio_console_port {
489            args.push(format!("--tokio-console-port={port}"));
490        }
491        if !self.encryption {
492            args.push("--no-encryption".to_string());
493        }
494        if let Some(fp) = self.master_cert_fingerprint {
495            args.push(format!(
496                "--master-cert-fp={}",
497                crate::tls::fingerprint_to_hex(&fp)
498            ));
499        }
500        // propagate the adaptive metadata-ops throttle settings to rcpd so a
501        // remote copy uses the same control law as the master-side tool.
502        if let Some(auto) = &self.auto_meta {
503            args.push("--auto-meta-throttle".to_string());
504            args.push(format!("--auto-meta-initial-cwnd={}", auto.initial_cwnd));
505            args.push(format!("--auto-meta-min-cwnd={}", auto.min_cwnd));
506            args.push(format!("--auto-meta-max-cwnd={}", auto.max_cwnd));
507            args.push(format!("--auto-meta-alpha={}", auto.alpha));
508            args.push(format!("--auto-meta-beta={}", auto.beta));
509            args.push(format!(
510                "--auto-meta-baseline-percentile={}",
511                auto.baseline_percentile,
512            ));
513            args.push(format!(
514                "--auto-meta-current-percentile={}",
515                auto.current_percentile,
516            ));
517            args.push(format!("--auto-meta-increase-step={}", auto.increase_step));
518            args.push(format!("--auto-meta-decrease-step={}", auto.decrease_step));
519            args.push(format!(
520                "--auto-meta-long-window={}",
521                humantime::format_duration(auto.long_window),
522            ));
523            args.push(format!(
524                "--auto-meta-short-window={}",
525                humantime::format_duration(auto.short_window),
526            ));
527            args.push(format!(
528                "--auto-meta-tick-interval={}",
529                humantime::format_duration(auto.tick_interval),
530            ));
531        }
532        // Only forward histogram flags when there's a log path: the panel-
533        // only flag (--auto-meta-histogram) makes rcpd pay the synchronous
534        // accumulator lock cost on every probe, but rcpd's panel never
535        // reaches the user (the master's remote-progress renderer doesn't
536        // read the rcpd histogram registry). The log path is different —
537        // it produces a concrete artifact on the rcpd's host that the user
538        // can collect after the run.
539        if let Some(path) = &self.auto_meta_histogram_log {
540            args.push(format!("--auto-meta-histogram-log={path}"));
541            args.push(format!(
542                "--auto-meta-histogram-interval={}",
543                humantime::format_duration(self.auto_meta_histogram_interval),
544            ));
545        }
546        args
547    }
548}
549
550#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
551pub enum RcpdRole {
552    Source,
553    Destination,
554}
555
556impl std::fmt::Display for RcpdRole {
557    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558        match self {
559            RcpdRole::Source => write!(f, "source"),
560            RcpdRole::Destination => write!(f, "destination"),
561        }
562    }
563}
564
565impl std::str::FromStr for RcpdRole {
566    type Err = anyhow::Error;
567    fn from_str(s: &str) -> Result<Self, Self::Err> {
568        match s.to_lowercase().as_str() {
569            "source" => Ok(RcpdRole::Source),
570            "destination" | "dest" => Ok(RcpdRole::Destination),
571            _ => Err(anyhow::anyhow!("invalid role: {}", s)),
572        }
573    }
574}
575
576#[derive(Clone, Debug, Deserialize, Serialize)]
577pub struct TracingHello {
578    pub role: RcpdRole,
579    /// true for tracing/progress connection, false for control connection
580    pub is_tracing: bool,
581}
582
583/// TLS certificate fingerprint (SHA-256 of DER-encoded certificate).
584pub type CertFingerprint = [u8; 32];
585
586#[derive(Clone, Debug, Deserialize, Serialize)]
587pub enum MasterHello {
588    Source {
589        src: std::path::PathBuf,
590        dst: std::path::PathBuf,
591        /// Destination's TLS certificate fingerprint (None if encryption disabled)
592        dest_cert_fingerprint: Option<CertFingerprint>,
593        /// Filter settings for include/exclude patterns (source-side filtering)
594        filter: Option<common::filter::FilterSettings>,
595        /// Dry-run mode for previewing operations
596        dry_run: Option<common::config::DryRunMode>,
597    },
598    Destination {
599        /// TCP address for control connection to source
600        source_control_addr: std::net::SocketAddr,
601        /// TCP address for data connections to source
602        source_data_addr: std::net::SocketAddr,
603        server_name: String,
604        preserve: common::preserve::Settings,
605        /// Source's TLS certificate fingerprint (None if encryption disabled)
606        source_cert_fingerprint: Option<CertFingerprint>,
607    },
608}
609
610#[derive(Clone, Debug, Deserialize, Serialize)]
611pub struct SourceMasterHello {
612    /// TCP address for control connection (bidirectional messages)
613    pub control_addr: std::net::SocketAddr,
614    /// TCP address for data connections (file transfers)
615    pub data_addr: std::net::SocketAddr,
616    pub server_name: String,
617}
618
619// re-export RuntimeStats from common for convenience
620pub use common::RuntimeStats;
621
622#[derive(Clone, Debug, Deserialize, Serialize)]
623pub enum RcpdResult {
624    Success {
625        message: String,
626        summary: common::copy::Summary,
627        runtime_stats: common::RuntimeStats,
628    },
629    Failure {
630        error: String,
631        summary: common::copy::Summary,
632        runtime_stats: common::RuntimeStats,
633    },
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    fn mk_entry(name: &str) -> ExistingEntry {
641        ExistingEntry {
642            name: std::path::PathBuf::from(name),
643            is_file: true,
644            metadata: Metadata {
645                mode: 0o644,
646                uid: 0,
647                gid: 0,
648                atime: 0,
649                mtime: 0,
650                atime_nsec: 0,
651                mtime_nsec: 0,
652            },
653            size: 0,
654        }
655    }
656
657    #[test]
658    fn chunk_manifest_empty_yields_no_chunks() {
659        assert!(chunk_manifest(vec![], MANIFEST_CHUNK_BYTE_BUDGET).is_empty());
660    }
661
662    #[test]
663    fn chunk_manifest_small_is_single_chunk() {
664        let entries: Vec<_> = (0..100).map(|i| mk_entry(&format!("f{i}.txt"))).collect();
665        let chunks = chunk_manifest(entries, MANIFEST_CHUNK_BYTE_BUDGET);
666        assert_eq!(chunks.len(), 1);
667        assert_eq!(chunks[0].len(), 100);
668    }
669
670    #[test]
671    fn chunk_manifest_splits_and_preserves_all_entries_in_order() {
672        let entries: Vec<_> = (0..1000)
673            .map(|i| mk_entry(&format!("file_{i:04}.dat")))
674            .collect();
675        // a tiny budget forces many chunks (each entry estimate is name.len() + 64 ≈ ~78 bytes)
676        let chunks = chunk_manifest(entries.clone(), 256);
677        assert!(
678            chunks.len() > 1,
679            "tiny budget should produce multiple chunks"
680        );
681        // frame-safety property: every multi-entry chunk stays within budget, so a chunk frame
682        // never approaches the control stream's frame limit
683        for chunk in &chunks {
684            if chunk.len() > 1 {
685                let total: usize = chunk.iter().map(estimate_entry_size).sum();
686                assert!(total <= 256, "multi-entry chunk exceeds budget: {total}");
687            }
688        }
689        // reassembly invariant: concat preserves every entry, in order, with nothing lost
690        let flat: Vec<_> = chunks.into_iter().flatten().collect();
691        assert_eq!(flat.len(), entries.len());
692        for (got, want) in flat.iter().zip(entries.iter()) {
693            assert_eq!(got.name, want.name);
694        }
695    }
696
697    #[test]
698    fn chunk_manifest_entry_larger_than_budget_gets_its_own_chunk() {
699        // budget smaller than a single entry: each still lands in its own chunk (never dropped).
700        let chunks = chunk_manifest(vec![mk_entry("a"), mk_entry("b")], 1);
701        assert_eq!(chunks.len(), 2);
702        assert_eq!(chunks[0].len(), 1);
703        assert_eq!(chunks[1].len(), 1);
704    }
705
706    #[test]
707    fn chunk_manifest_splits_a_large_manifest_at_the_production_budget() {
708        // a realistically large directory manifest (more than two budgets' worth) must split into
709        // multiple frames with the PRODUCTION budget — not just an artificially tiny one — so a
710        // change to the budget or to `estimate_entry_size` that breaks chunking at real scale is
711        // caught. built in memory (no files), so it stays a fast unit test.
712        let per_entry = estimate_entry_size(&mk_entry("file_0000000.dat"));
713        let n = (MANIFEST_CHUNK_BYTE_BUDGET / per_entry) * 2 + 1000;
714        let entries: Vec<_> = (0..n)
715            .map(|i| mk_entry(&format!("file_{i:07}.dat")))
716            .collect();
717        let chunks = chunk_manifest(entries.clone(), MANIFEST_CHUNK_BYTE_BUDGET);
718        assert!(
719            chunks.len() > 1,
720            "a manifest larger than the 4 MiB budget must span multiple chunks, got {}",
721            chunks.len()
722        );
723        // every multi-entry chunk stays within the real budget (frame-safety)
724        for chunk in &chunks {
725            if chunk.len() > 1 {
726                let total: usize = chunk.iter().map(estimate_entry_size).sum();
727                assert!(
728                    total <= MANIFEST_CHUNK_BYTE_BUDGET,
729                    "a chunk exceeds the production budget: {total}"
730                );
731            }
732        }
733        // reassembly invariant at production scale: concat preserves every entry, in order
734        let flat: Vec<_> = chunks.into_iter().flatten().collect();
735        assert_eq!(
736            flat.len(),
737            entries.len(),
738            "reassembly must preserve every entry"
739        );
740        assert!(
741            flat.iter().zip(&entries).all(|(a, b)| a.name == b.name),
742            "reassembly must preserve order"
743        );
744    }
745
746    fn minimal_rcpd_config() -> RcpdConfig {
747        RcpdConfig {
748            verbose: 0,
749            fail_early: false,
750            max_workers: 0,
751            max_blocking_threads: 0,
752            max_open_files: None,
753            ops_throttle: 0,
754            iops_throttle: 0,
755            chunk_size: 0,
756            auto_meta: None,
757            auto_meta_histogram: false,
758            auto_meta_histogram_log: None,
759            auto_meta_histogram_interval: std::time::Duration::from_secs(1),
760            dereference: false,
761            overwrite: false,
762            overwrite_compare: "size,mtime".to_string(),
763            overwrite_filter: None,
764            ignore_existing: false,
765            skip_specials: false,
766            debug_log_prefix: None,
767            port_ranges: None,
768            progress: false,
769            progress_delay: None,
770            remote_copy_conn_timeout_sec: 30,
771            network_profile: crate::NetworkProfile::default(),
772            buffer_size: None,
773            max_connections: 1,
774            pending_writes_multiplier: 1,
775            chrome_trace_prefix: None,
776            flamegraph_prefix: None,
777            profile_level: None,
778            tokio_console: false,
779            tokio_console_port: None,
780            encryption: true,
781            master_cert_fingerprint: None,
782            overwrite_manifest_max_entries: DEFAULT_OVERWRITE_MANIFEST_MAX_ENTRIES,
783        }
784    }
785
786    #[test]
787    fn to_args_includes_overwrite_manifest_max_entries() {
788        let mut config = minimal_rcpd_config();
789        config.overwrite_manifest_max_entries = 123_456;
790        let args = config.to_args();
791        assert!(
792            args.iter()
793                .any(|a| a == "--overwrite-manifest-max-entries=123456"),
794            "expected manifest cap flag in {args:?}"
795        );
796    }
797
798    #[test]
799    fn to_args_omits_auto_meta_throttle_when_none() {
800        let args = minimal_rcpd_config().to_args();
801        // throttle-specific flags must be absent when auto_meta is None
802        let throttle_flags = [
803            "--auto-meta-throttle",
804            "--auto-meta-initial-cwnd",
805            "--auto-meta-min-cwnd",
806            "--auto-meta-max-cwnd",
807            "--auto-meta-alpha",
808            "--auto-meta-beta",
809            "--auto-meta-baseline-percentile",
810            "--auto-meta-current-percentile",
811            "--auto-meta-increase-step",
812            "--auto-meta-decrease-step",
813            "--auto-meta-long-window",
814            "--auto-meta-short-window",
815            "--auto-meta-tick-interval",
816        ];
817        for flag in throttle_flags {
818            assert!(
819                !args.iter().any(|a| a.starts_with(flag)),
820                "throttle flag {flag} should not be emitted when auto_meta is None: {args:?}",
821            );
822        }
823        // histogram flag, log, and interval must all be absent when histograms are off
824        for arg in &args {
825            assert!(
826                !arg.starts_with("--auto-meta-histogram"),
827                "must not emit any histogram flag when histograms are off, found: {arg}",
828            );
829        }
830    }
831
832    #[test]
833    fn to_args_propagates_all_auto_meta_fields() {
834        let mut config = minimal_rcpd_config();
835        config.auto_meta = Some(common::AutoMetaThrottleConfig {
836            initial_cwnd: 8,
837            min_cwnd: 2,
838            max_cwnd: 128,
839            alpha: 1.2,
840            beta: 1.6,
841            increase_step: 2,
842            decrease_step: 3,
843            baseline_percentile: 0.4,
844            current_percentile: 0.6,
845            long_window: std::time::Duration::from_secs(20),
846            short_window: std::time::Duration::from_secs(2),
847            tick_interval: std::time::Duration::from_millis(75),
848        });
849        let args = config.to_args();
850        let has = |needle: &str| args.iter().any(|a| a == needle);
851        let has_prefix = |needle: &str| args.iter().any(|a| a.starts_with(needle));
852        assert!(has("--auto-meta-throttle"));
853        assert!(has("--auto-meta-initial-cwnd=8"));
854        assert!(has("--auto-meta-min-cwnd=2"));
855        assert!(has("--auto-meta-max-cwnd=128"));
856        assert!(has_prefix("--auto-meta-alpha=1.2"));
857        assert!(has_prefix("--auto-meta-beta=1.6"));
858        assert!(has_prefix("--auto-meta-baseline-percentile=0.4"));
859        assert!(has_prefix("--auto-meta-current-percentile=0.6"));
860        assert!(has("--auto-meta-increase-step=2"));
861        assert!(has("--auto-meta-decrease-step=3"));
862        assert!(has_prefix("--auto-meta-long-window="));
863        assert!(has_prefix("--auto-meta-short-window="));
864        assert!(has_prefix("--auto-meta-tick-interval="));
865    }
866
867    #[test]
868    fn to_args_omits_histogram_flags_when_disabled() {
869        // Critical for backward compatibility: existing rcpd binaries
870        // built without histogram support reject --auto-meta-histogram-*
871        // flags, so we must not emit them on every remote copy.
872        let mut config = minimal_rcpd_config();
873        config.auto_meta_histogram = false;
874        config.auto_meta_histogram_log = None;
875        let args = config.to_args();
876        for arg in &args {
877            assert!(
878                !arg.starts_with("--auto-meta-histogram"),
879                "must not emit histogram flag when disabled, found: {arg}",
880            );
881        }
882    }
883
884    #[test]
885    fn to_args_omits_panel_only_flag_when_no_log_path() {
886        // Panel-only --auto-meta-histogram is intentionally NOT forwarded
887        // to rcpd: the panel never reaches the user (no plumbing in remote
888        // progress), and forwarding would just add per-probe lock cost.
889        let mut config = minimal_rcpd_config();
890        config.auto_meta_histogram = true;
891        config.auto_meta_histogram_log = None;
892        let args = config.to_args();
893        for arg in &args {
894            assert!(
895                !arg.starts_with("--auto-meta-histogram"),
896                "panel-only flag must not be forwarded to rcpd, found: {arg}",
897            );
898        }
899    }
900
901    #[test]
902    fn to_args_forwards_histogram_log_and_interval_when_log_path_set() {
903        let mut config = minimal_rcpd_config();
904        config.auto_meta_histogram = false; // panel-only off
905        config.auto_meta_histogram_log = Some("/tmp/foo.hdr".into());
906        config.auto_meta_histogram_interval = std::time::Duration::from_millis(500);
907        let args = config.to_args();
908        assert!(
909            args.iter()
910                .any(|a| a == "--auto-meta-histogram-log=/tmp/foo.hdr")
911        );
912        assert!(
913            args.iter()
914                .any(|a| a.starts_with("--auto-meta-histogram-interval="))
915        );
916        // The bare panel flag is NOT pushed; the log flag at parse time on
917        // rcpd already implies the accumulator pipeline.
918        assert!(
919            !args.iter().any(|a| a == "--auto-meta-histogram"),
920            "panel-only flag must not be forwarded; the log flag implies the pipeline",
921        );
922    }
923}