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}