chithi/
args.rs

1//  Chithi: OpenZFS replication tools
2//  Copyright (C) 2025-2026  Ifaz Kabir
3
4//  This program is free software: you can redistribute it and/or modify
5//  it under the terms of the GNU General Public License as published by
6//  the Free Software Foundation, either version 3 of the License, or
7//  (at your option) any later version.
8
9//  This program is distributed in the hope that it will be useful,
10//  but WITHOUT ANY WARRANTY; without even the implied warranty of
11//  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12//  GNU General Public License for more details.
13
14//  You should have received a copy of the GNU General Public License
15//  along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17use crate::compress::Compress;
18use crate::send_recv_opts::{OptionsLine, Opts};
19use crate::zfs;
20use bw::Bytes;
21use chrono::format::StrftimeItems;
22use clap::{Parser, Subcommand};
23use regex_lite::Regex;
24use std::collections::HashSet;
25use std::ffi::OsString;
26
27mod bw;
28pub mod run;
29
30#[derive(Debug, Parser)]
31#[command(name = "chithi")]
32#[command(version, about = "ZFS snapshot replication tool", long_about = None)]
33pub struct Cli {
34    #[command(subcommand)]
35    pub command: Commands,
36}
37
38#[allow(clippy::large_enum_variant)]
39#[derive(Debug, Subcommand)]
40pub enum Commands {
41    /// Replicates a dataset to another pool
42    Sync(Args),
43    #[command(external_subcommand)]
44    External(Vec<OsString>),
45}
46
47#[derive(Parser, Debug)]
48#[command(about, long_about = None)]
49pub struct Args {
50    /// Compresses data during transfer. Currently accepted options are gzip,
51    /// pigz-fast, pigz-slow, zstd-fast, zstdmt-fast, zstd-slow, zstdmt-slow,
52    /// lz4, xz, lzo & none
53    #[arg(long, default_value = "lzo", value_name = "FORMAT", value_parser = Compress::try_from_str)]
54    pub compress: Compress,
55
56    /// Extra identifier which is included in the snapshot name. Can be used for
57    /// replicating to multiple targets.
58    #[arg(long, value_name = "EXTRA", value_parser = Args::validate_identifier)]
59    pub identifier: Option<String>,
60
61    /// Also transfers child datasets
62    #[arg(short, long)]
63    pub recursive: bool,
64
65    /// Skips syncing of the parent dataset. Does nothing without '--recursive' option.
66    #[arg(long, requires = "recursive")]
67    pub skip_parent: bool,
68
69    /// Bandwidth limit in bytes/kbytes/etc per second on the source transfer
70    #[arg(long, value_parser = Bytes::try_from_str)]
71    pub source_bwlimit: Option<Bytes>,
72
73    /// Bandwidth limit in bytes/kbytes/etc per second on the target transfer
74    #[arg(long, value_parser = Bytes::try_from_str)]
75    pub target_bwlimit: Option<Bytes>,
76
77    /// Specify the mbuffer size, please refer to mbuffer(1) manual page.
78    #[arg(long, default_value = "16M", value_name = "VALUE")]
79    pub mbuffer_size: String,
80
81    /// Configure how pv displays the progress bar
82    #[arg(long, default_value = "-p -t -e -r -b", value_name = "OPTIONS")]
83    pub pv_options: String,
84
85    /// Replicates using newest snapshot instead of intermediates
86    #[arg(long)]
87    pub no_stream: bool,
88
89    /// Timestamp format. All invalid characters in the format will be dropped.
90    /// Formatting details can be found in the chrono::format::strftime
91    /// documentation.
92    #[arg(long, default_value="%Y-%m-%d:%H:%M:%S-GMT%:z", value_parser = Args::validate_timestamp)]
93    pub timestamp_format: String,
94
95    /// Does not create new snapshot, only transfers existing
96    #[arg(long)]
97    pub no_sync_snap: bool,
98
99    /// Does not prune sync snaps at the end of transfers
100    #[arg(long)]
101    pub keep_sync_snap: bool,
102
103    /// Creates a zfs bookmark for the newest snapshot on source after replication succeeds.
104    /// Unless --syncoid-bookmarks is set, the bookmark name includes the
105    /// identifier if set.
106    #[arg(long)]
107    pub create_bookmark: bool,
108
109    /// Use the sanoid/syncoid 2.3 bookmark behaviour. This should be treated as
110    /// an experinmental feature, and may not be kept in future minor revisions.
111    #[arg(long, requires = "no_sync_snap", requires = "create_bookmark")]
112    pub syncoid_bookmarks: bool,
113
114    /// Use "syncoid:sync" property to check if we should sync sync. This should
115    /// be treated as an experinmental feature, and may not be kept in future
116    /// minor revisions.
117    #[arg(long)]
118    pub syncoid_sync_check: bool,
119
120    /// If transfer creates new sync snaps, this option chooses what kind of
121    /// snapshot formats to prune at the end of transfers. Current options are
122    /// syncoid and chithi. Needs to be passed multiple times for multiple
123    /// formats.
124    #[arg(
125        long = "prune-format",
126        default_value = "chithi",
127        value_name = "SNAPFORMAT"
128    )]
129    pub prune_formats: Vec<String>,
130
131    /// Adds a hold to the newest snapshot on the source and target after
132    /// replication and removes the hold after the next successful replication.
133    /// The hold name includes the identifier if set. This allows for separate
134    /// holds in case of multiple targets. Can be optionally passed the value
135    /// "syncoid" to make syncoid compatible holds.
136    #[arg(long, default_value = "false", default_missing_value = "true", value_parser = ["true", "false", "syncoid"], num_args = 0..=1)]
137    pub use_hold: String,
138
139    /// Preserves the recordsize on inital sends to the target
140    #[arg(long, conflicts_with = "preserve_properties")]
141    pub preserve_recordsize: bool,
142
143    /// Preserves locally set dataset properties similar to the zfs send -p
144    /// flag, but will also work for encrypted datasets in non raw sends.
145    /// Properties are manually fetched on the source and manually written to on
146    /// the target, with a blacklist of properties that cannot be written.
147    #[arg(long, conflicts_with = "preserve_recordsize")]
148    pub preserve_properties: bool,
149
150    /// Does not rollback snapshots on target (it probably requires a readonly target)
151    #[arg(long)]
152    pub no_rollback: bool,
153
154    /// With this argument, snapshots which are missing on the source will be
155    /// destroyed on the target. Use this if you only want to handle snapshots
156    /// on the source.
157    #[arg(long)]
158    pub delete_target_snapshots: bool,
159
160    /// Exclude specific datasets that match the given regular expression. Can be specified multiple times.
161    #[arg(long, value_name = "REGEX")]
162    pub exclude_datasets: Vec<Regex>,
163
164    /// Exclude specific snapshots that match the given regular expression. Can be specified multiple times. If a snapshot matches both exclude-snaps and include-snaps patterns, then it will be excluded.
165    #[arg(long, value_name = "REGEX")]
166    pub exclude_snaps: Vec<Regex>,
167
168    /// Only include snapshots that match the given regular expression. Can be specified multiple times. If a snapshot matches both exclude-snaps and include-snaps patterns, then it will be excluded.
169    #[arg(long, value_name = "REGEX")]
170    pub include_snaps: Vec<Regex>,
171
172    /// Use bookmarks for incremental syncing. When set to "always" (assumed if
173    /// no value is passed), we fetch bookmarks as well as snapshots when
174    /// computing incremental sends.
175    #[arg(long, default_value = "fallback", default_missing_value = "always", value_parser = ["always", "fallback"], num_args = 0..=1)]
176    pub use_bookmarks: String,
177
178    /// Prune bookmarks. Bookmarks are not
179    #[arg(long, conflicts_with = "syncoid_bookmarks")]
180    pub max_bookmarks: Option<std::num::NonZero<usize>>,
181
182    /// Use advanced options for zfs send (the arguments are filtered as needed), e.g. chithi --send-options="Lc e" sets zfs send -L -c -e ...
183    #[arg(long, value_name = "OPTIONS", value_parser = Opts::try_from_str, default_value_t)]
184    pub send_options: Opts<Vec<OptionsLine<String>>>,
185
186    /// Use advanced options for zfs receive (the arguments are filtered as needed), e.g. chithi --recv-options="ux recordsize o compression=lz4" sets zfs receive -u -x recordsize -o compression=lz4 ...
187    #[arg(long, value_name = "OPTIONS", value_parser = Opts::try_from_str, default_value_t)]
188    pub recv_options: Opts<Vec<OptionsLine<String>>>,
189
190    /// Passes CIPHER to ssh to use a particular cipher set.
191    #[arg(short = 'c', long, value_name = "CIPHER")]
192    pub ssh_cipher: Option<String>,
193
194    /// Connects to remote machines on a particular port.
195    #[arg(short = 'P', long, value_name = "PORT")]
196    pub ssh_port: Option<String>,
197
198    /// Uses config FILE for connecting to remote machines over ssh.
199    #[arg(short = 'F', long, value_name = "FILE")]
200    pub ssh_config: Option<String>,
201
202    /// Uses identity FILE to connect to remote machines over ssh.
203    #[arg(short = 'i', long, value_name = "FILE")]
204    pub ssh_identity: Option<String>,
205
206    /// Passes OPTION to ssh for remote usage. Can be specified multiple times
207    #[arg(short = 'o', long = "ssh-option", value_name = "OPTION")]
208    pub ssh_options: Vec<String>,
209
210    /// Prints out a lot of additional information during a chithi run. Logs overridden by --quiet and RUST_LOG environment variable
211    #[arg(long)]
212    pub debug: bool,
213
214    /// Supresses non-error output and progress bars. Logs overridden by RUST_LOG environment variable
215    #[arg(long)]
216    pub quiet: bool,
217
218    /// Dumps a list of snapshots during the run
219    #[arg(long)]
220    pub dump_snaps: bool,
221
222    /// Passes OPTION to ssh for remote usage. Can be specified multiple times
223    #[arg(long)]
224    pub no_command_checks: bool,
225
226    /// A comma separated list of optional commands to skip. Current values are:
227    /// sourcepv localpv targetpv compress localcompress sourcembuffer
228    /// targetmbuffer localmbuffer
229    #[arg(long, value_parser = Args::get_commands_to_skip, default_value = "")]
230    pub skip_optional_commands: HashSet<&'static str>,
231
232    /// Do a dry run, without modifying datasets and pools. The dry run
233    /// functionality is provided on a best effort basis and may break between
234    /// minor versions.
235    #[arg(long)]
236    pub dry_run: bool,
237
238    /// Don't use the ZFS resume feature if available
239    #[arg(long)]
240    pub no_resume: bool,
241
242    /// Don't try to recreate clones on target. Clone handling is done by
243    /// deferring child datasets that are clones to a second pass of syncing, so
244    /// this flag is not meaningful without the --recursive flag.
245    #[arg(long, requires = "recursive")]
246    pub no_clone_handling: bool,
247
248    /// Bypass the root check, for use with ZFS permission delegation
249    #[arg(long)]
250    pub no_privilege_elevation: bool,
251
252    /// Manually specifying source host (and user)
253    #[arg(long)]
254    pub source_host: Option<String>,
255
256    /// Manually specifying target host (and user)
257    #[arg(long)]
258    pub target_host: Option<String>,
259
260    /// Remove target datasets recursively if there are no matching snapshots/bookmarks (also overwrites conflicting named snapshots)
261    #[arg(long)]
262    pub force_delete: bool,
263
264    /// Prevents the recursive recv check at the start of the sync
265    #[arg(long, requires = "recursive")]
266    pub no_recv_check_start: bool,
267
268    pub source: String,
269
270    pub target: String,
271}
272
273impl Args {
274    pub fn clone_handling(&self) -> bool {
275        !self.no_clone_handling
276    }
277    pub fn recv_check_start(&self) -> bool {
278        !self.no_recv_check_start
279    }
280    /// Fills in the optional_commands_to_skip field
281    fn get_commands_to_skip(commands: &str) -> Result<HashSet<&'static str>, String> {
282        let mut res = HashSet::new();
283        let commands = commands.trim();
284        if commands.is_empty() {
285            return Ok(res);
286        }
287        for command in commands.split(',') {
288            match command {
289                "sourcepv" => {
290                    res.insert("sourcepv");
291                }
292                "targetpv" => {
293                    res.insert("targetpv");
294                }
295                "localpv" => {
296                    res.insert("localpv");
297                }
298                "compress" => {
299                    res.insert("compress");
300                }
301                "localcompress" => {
302                    res.insert("localcompress");
303                }
304                "sourcembuffer" => {
305                    res.insert("sourcembuffer");
306                }
307                "targetmbuffer" => {
308                    res.insert("targetmbuffer");
309                }
310                "localmbuffer" => {
311                    res.insert("localmbuffer");
312                }
313                s => {
314                    return Err(format!(
315                        "unsupported command {s} passed to --skip-optional-commands"
316                    ));
317                }
318            }
319        }
320        Ok(res)
321    }
322
323    pub fn optional_enabled(&self, optional: &'static str) -> bool {
324        !self.skip_optional_commands.contains(optional)
325    }
326
327    pub fn get_pv_options(&self) -> Vec<OsString> {
328        self.pv_options
329            .split_whitespace()
330            .map(Into::into)
331            .collect::<Vec<_>>()
332    }
333
334    pub fn get_source_mbuffer_args(&self) -> Vec<OsString> {
335        let mut args = vec!["-q", "-s", "128k", "-m", self.mbuffer_size.as_str()];
336        if let Some(limit) = &self.source_bwlimit {
337            args.push("-R");
338            args.push(&limit.str);
339        }
340        args.iter().map(Into::into).collect()
341    }
342
343    pub fn get_target_mbuffer_args(&self) -> Vec<OsString> {
344        let mut args = vec!["-q", "-s", "128k", "-m", self.mbuffer_size.as_str()];
345        if let Some(limit) = &self.target_bwlimit {
346            args.push("-r");
347            args.push(&limit.str);
348        }
349        args.iter().map(Into::into).collect()
350    }
351
352    pub fn get_timestamp(&self) -> String {
353        let now = chrono::Local::now();
354        let formatted = format!("{}", now.format(&self.timestamp_format));
355        formatted
356            .chars()
357            .filter(|&c| zfs::is_component_char(c))
358            .collect()
359    }
360
361    /// Returns false for now. In the future, we might allow direct ssh/tls (or
362    /// even insecure tcp) connections between remote hosts.
363    pub fn direct_connection(&self) -> bool {
364        false
365    }
366
367    fn validate_identifier(value: &str) -> Result<String, &'static str> {
368        fn invalid_char(c: char) -> bool {
369            !zfs::is_component_char(c)
370        }
371        if value.contains(invalid_char) {
372            Err("extra indentifier contains invalid chars!")
373        } else {
374            Ok(value.to_string())
375        }
376    }
377
378    fn validate_timestamp(value: &str) -> Result<String, &'static str> {
379        if StrftimeItems::new(value).parse().is_ok() {
380            return Ok(value.to_string());
381        }
382        Err("invalid timestamp format see chrono time formats")
383    }
384}