1mod old_style;
2pub mod value;
3
4#[doc(hidden)]
5pub use old_style::{expand_bsdtar_old_style_args, expand_bsdtar_w_option};
6
7use crate::{
8 command::{
9 append::AppendCommand, bugreport::BugReportCommand, compat::CompatCommand,
10 complete::CompleteCommand, concat::ConcatCommand, core::Umask, create::CreateCommand,
11 delete::DeleteCommand, experimental::ExperimentalCommand, extract::ExtractCommand,
12 list::ListCommand, sort::SortCommand, split::SplitCommand, strip::StripCommand,
13 xattr::XattrCommand,
14 },
15 utils::{fs::current_umask, process::is_running_as_root},
16};
17use clap::{ArgGroup, Parser, Subcommand, ValueEnum, ValueHint};
18use log::{Level, LevelFilter};
19use pna::HashAlgorithm;
20use std::{io, path::PathBuf};
21pub(crate) use value::*;
22
23#[derive(Parser, Clone, Debug)]
24#[command(
25 name = env!("CARGO_PKG_NAME"),
26 version,
27 about,
28 author,
29 arg_required_else_help = true,
30)]
31pub struct Cli {
32 #[command(subcommand)]
33 pub(crate) commands: Commands,
34 #[command(flatten)]
35 pub(crate) global: GlobalArgs,
36}
37
38#[derive(Parser, Clone, Eq, PartialEq, Debug, Default)]
39pub(crate) struct GlobalArgs {
40 #[command(flatten)]
41 pub(crate) verbosity: VerbosityArgs,
42 #[command(flatten)]
43 pub(crate) color: ColorArgs,
44 #[arg(
45 long,
46 global = true,
47 help = "Enable experimental options. Required for flags marked as unstable; behavior may change or be removed."
48 )]
49 pub(crate) unstable: bool,
50}
51
52impl GlobalArgs {
53 #[inline]
54 pub(crate) fn color(&self) -> ColorChoice {
55 self.color.color
56 }
57}
58
59#[derive(Debug)]
70pub(crate) struct GlobalContext {
71 args: GlobalArgs,
72 umask: Umask,
73 is_root: bool,
74}
75
76impl GlobalContext {
77 pub(crate) fn new(args: GlobalArgs) -> Self {
82 Self {
83 umask: Umask::new(current_umask()),
84 is_root: is_running_as_root(),
85 args,
86 }
87 }
88
89 #[inline]
91 pub(crate) fn umask(&self) -> Umask {
92 self.umask
93 }
94
95 #[inline]
97 pub(crate) fn is_root(&self) -> bool {
98 self.is_root
99 }
100
101 #[inline]
103 pub(crate) fn color(&self) -> ColorChoice {
104 self.args.color()
105 }
106
107 #[inline]
109 pub(crate) fn unstable(&self) -> bool {
110 self.args.unstable
111 }
112}
113
114impl Cli {
115 pub fn init_logger(&self) -> io::Result<()> {
116 let level = self.global.verbosity.log_level_filter();
117 let base = fern::Dispatch::new();
118 let stderr = fern::Dispatch::new()
119 .level(level)
120 .format(|out, msg, rec| match rec.level() {
121 Level::Error => out.finish(format_args!("error: {msg}")),
122 Level::Warn => out.finish(format_args!("warning: {msg}")),
123 Level::Info | Level::Debug | Level::Trace => out.finish(*msg),
124 })
125 .chain(io::stderr());
126 base.chain(stderr).apply().map_err(io::Error::other)
127 }
128}
129
130#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
131#[command(group(ArgGroup::new("verbosity").args(["quiet", "verbose", "log_level"])))]
132pub(crate) struct VerbosityArgs {
133 #[arg(
134 long,
135 global = true,
136 help = "Make some output more quiet (alias for --log-level off)"
137 )]
138 quiet: bool,
139 #[arg(
140 long,
141 global = true,
142 help = "Make some output more verbose (alias for --log-level debug)"
143 )]
144 verbose: bool,
145 #[arg(
146 long,
147 global = true,
148 value_name = "LEVEL",
149 default_value = "warn",
150 help = "Set the log level"
151 )]
152 log_level: LogLevel,
153}
154
155impl VerbosityArgs {
156 #[inline]
157 pub(crate) const fn log_level_filter(&self) -> LevelFilter {
158 match (self.quiet, self.verbose) {
159 (true, _) => LevelFilter::Off,
160 (_, true) => LevelFilter::Debug,
161 (_, _) => self.log_level.as_level_filter(),
162 }
163 }
164}
165
166#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
167pub(crate) struct ColorArgs {
168 #[arg(
169 long,
170 global = true,
171 value_name = "WHEN",
172 default_value = "auto",
173 help = "Control color output"
174 )]
175 pub(crate) color: ColorChoice,
176}
177
178#[derive(Subcommand, Clone, Debug)]
179pub(crate) enum Commands {
180 #[command(visible_alias = "c", about = "Create archive")]
181 Create(CreateCommand),
182 #[command(visible_alias = "a", about = "Append files to archive")]
183 Append(AppendCommand),
184 #[command(visible_alias = "x", about = "Extract files from archive")]
185 Extract(ExtractCommand),
186 #[command(visible_aliases = &["l", "ls"], about = "List files in archive")]
187 List(ListCommand),
188 #[command(about = "Delete entry from archive")]
189 Delete(DeleteCommand),
190 #[command(about = "Split archive")]
191 Split(SplitCommand),
192 #[command(about = "Concat archives")]
193 Concat(ConcatCommand),
194 #[command(about = "Strip entries metadata")]
195 Strip(StripCommand),
196 #[command(about = "Sort entries in archive")]
197 Sort(SortCommand),
198 #[command(about = "Manipulate extended attributes")]
199 Xattr(XattrCommand),
200 #[command(about = "Generate shell auto complete")]
201 Complete(CompleteCommand),
202 #[command(about = "Generate bug report template")]
203 BugReport(BugReportCommand),
204 #[command(about = "Compatibility interface for other archive tools")]
205 Compat(CompatCommand),
206 #[command(
207 about = "Unstable experimental commands; behavior and interface may change or be removed"
208 )]
209 Experimental(ExperimentalCommand),
210}
211
212#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
213pub(crate) struct FileArgs {
214 #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)]
215 pub(crate) archive: PathBuf,
216 #[arg(value_hint = ValueHint::FilePath)]
217 pub(crate) files: Vec<String>,
218}
219
220#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
227#[command(
228 group(
229 ArgGroup::new("archive-args")
230 .args(["file", "archive"])
231 .multiple(true)
232 .required(true)
233 )
234)]
235pub(crate) struct FileArgsCompat {
236 #[arg(short, long, help = "Archive file path", value_hint = ValueHint::FilePath)]
237 file: Option<PathBuf>,
238 #[arg(help = "Archive file path (deprecated, use --file)", value_hint = ValueHint::FilePath)]
239 archive: Option<PathBuf>,
240 #[arg(help = "Files or directories to process", value_hint = ValueHint::FilePath)]
241 files: Vec<String>,
242}
243
244impl FileArgsCompat {
245 #[inline]
246 pub(crate) fn archive(&self) -> PathBuf {
247 if let Some(file) = &self.file {
248 file.clone()
249 } else if let Some(archive) = &self.archive {
250 log::warn!("positional `archive` is deprecated, use `--file` instead");
251 archive.clone()
252 } else {
253 unreachable!()
254 }
255 }
256
257 #[inline]
258 pub(crate) fn files(&self) -> Vec<String> {
259 if self.file.is_none() {
260 self.files.clone()
261 } else {
262 let mut files = self.files.clone();
263 if let Some(archive) = &self.archive {
264 files.insert(0, archive.to_string_lossy().to_string());
265 }
266 files
267 }
268 }
269}
270
271#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
272#[command(group(ArgGroup::new("password_provider").args(["password", "password_file"])))]
273pub(crate) struct PasswordArgs {
274 #[arg(
275 long,
276 visible_alias = "passphrase",
277 help = "Password of archive. If password is not given it's asked from the tty"
278 )]
279 pub(crate) password: Option<Option<String>>,
280 #[arg(long, value_name = "FILE", help = "Read password from specified file")]
281 pub(crate) password_file: Option<PathBuf>,
282}
283
284#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
285#[command(group(ArgGroup::new("transform_strategy").args(["unsolid", "keep_solid"])))]
286pub(crate) struct SolidEntriesTransformStrategyArgs {
287 #[arg(long, help = "Convert solid entries to regular entries")]
288 unsolid: bool,
289 #[arg(long, help = "Preserve solid entries without conversion")]
290 keep_solid: bool,
291}
292
293impl SolidEntriesTransformStrategyArgs {
294 #[inline]
295 pub(crate) const fn strategy(&self) -> SolidEntriesTransformStrategy {
296 if self.unsolid {
297 SolidEntriesTransformStrategy::UnSolid
298 } else {
299 SolidEntriesTransformStrategy::KeepSolid
300 }
301 }
302}
303
304#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
305pub(crate) enum SolidEntriesTransformStrategy {
306 UnSolid,
307 KeepSolid,
308}
309
310#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
311#[command(group(ArgGroup::new("compression_method").args(["store", "deflate", "zstd", "xz"])))]
312pub(crate) struct CompressionAlgorithmArgs {
313 #[arg(long, help = "No compression")]
314 pub(crate) store: bool,
315 #[arg(
316 long,
317 value_name = "level",
318 help = "Use deflate for compression [possible level: 1-9, min, max]"
319 )]
320 pub(crate) deflate: Option<Option<DeflateLevel>>,
321 #[arg(
322 long,
323 value_name = "level",
324 help = "Use zstd for compression [possible level: 1-21, min, max]"
325 )]
326 pub(crate) zstd: Option<Option<ZstdLevel>>,
327 #[arg(
328 long,
329 value_name = "level",
330 help = "Use xz for compression [possible level: 0-9, min, max]"
331 )]
332 pub(crate) xz: Option<Option<XzLevel>>,
333}
334
335impl CompressionAlgorithmArgs {
336 pub(crate) fn algorithm(&self) -> (pna::Compression, Option<pna::CompressionLevel>) {
337 if self.store {
338 (pna::Compression::No, None)
339 } else if let Some(level) = self.xz {
340 (pna::Compression::XZ, level.map(Into::into))
341 } else if let Some(level) = self.zstd {
342 (pna::Compression::ZStandard, level.map(Into::into))
343 } else if let Some(level) = self.deflate {
344 (pna::Compression::Deflate, level.map(Into::into))
345 } else {
346 (pna::Compression::ZStandard, None)
347 }
348 }
349}
350
351#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
352#[command(group(ArgGroup::new("cipher_algorithm").args(["aes", "camellia"])))]
353pub(crate) struct CipherAlgorithmArgs {
354 #[arg(long, value_name = "cipher mode", help = "Use aes for encryption")]
355 pub(crate) aes: Option<Option<CipherMode>>,
356 #[arg(long, value_name = "cipher mode", help = "Use camellia for encryption")]
357 pub(crate) camellia: Option<Option<CipherMode>>,
358}
359
360impl CipherAlgorithmArgs {
361 pub(crate) const fn algorithm(&self) -> pna::Encryption {
362 if self.aes.is_some() {
363 pna::Encryption::Aes
364 } else if self.camellia.is_some() {
365 pna::Encryption::Camellia
366 } else {
367 pna::Encryption::Aes
368 }
369 }
370
371 pub(crate) fn mode(&self) -> pna::CipherMode {
372 match match (self.aes, self.camellia) {
373 (Some(mode), _) | (_, Some(mode)) => mode.unwrap_or_default(),
374 (None, None) => CipherMode::default(),
375 } {
376 CipherMode::Cbc => pna::CipherMode::CBC,
377 CipherMode::Ctr => pna::CipherMode::CTR,
378 }
379 }
380}
381
382#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, ValueEnum)]
383pub(crate) enum CipherMode {
384 Cbc,
385 #[default]
386 Ctr,
387}
388
389#[derive(Parser, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
390#[command(group(ArgGroup::new("hash_algorithm").args(["argon2", "pbkdf2"])))]
391pub(crate) struct HashAlgorithmArgs {
392 #[arg(long, value_name = "PARAMS", help = "Use argon2 for password hashing")]
393 argon2: Option<Option<Argon2idParams>>,
394 #[arg(long, value_name = "PARAMS", help = "Use pbkdf2 for password hashing")]
395 pbkdf2: Option<Option<Pbkdf2Sha256Params>>,
396}
397
398impl HashAlgorithmArgs {
399 pub(crate) fn algorithm(&self) -> HashAlgorithm {
400 if let Some(Some(params)) = &self.pbkdf2 {
401 HashAlgorithm::pbkdf2_sha256_with(params.rounds)
402 } else if self.pbkdf2.as_ref().is_some_and(|it| it.is_none()) {
403 HashAlgorithm::pbkdf2_sha256()
404 } else if let Some(Some(params)) = &self.argon2 {
405 HashAlgorithm::argon2id_with(params.time, params.memory, params.parallelism)
406 } else {
407 HashAlgorithm::argon2id()
408 }
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn context_captures_umask() {
418 let args = GlobalArgs::default();
419 let ctx = GlobalContext::new(args);
420 assert!(ctx.umask().apply(0o777) <= 0o777);
421 }
422
423 #[test]
424 fn context_delegates_to_global_args() {
425 let args = GlobalArgs {
426 unstable: true,
427 ..Default::default()
428 };
429 let ctx = GlobalContext::new(args);
430 assert!(ctx.unstable());
431 }
432
433 #[test]
434 fn context_delegates_color_to_global_args() {
435 let args = GlobalArgs {
436 color: ColorArgs {
437 color: ColorChoice::Never,
438 },
439 ..Default::default()
440 };
441 let ctx = GlobalContext::new(args);
442 assert_eq!(ctx.color(), ColorChoice::Never);
443 }
444
445 #[test]
446 fn is_root_returns_consistent_result() {
447 let args = GlobalArgs::default();
448 let ctx1 = GlobalContext::new(args.clone());
449 let ctx2 = GlobalContext::new(args);
450 assert_eq!(ctx1.is_root(), ctx2.is_root());
451 }
452
453 #[test]
454 fn quiet_and_log_level_conflict() {
455 let result = Cli::try_parse_from([
456 "pna",
457 "--quiet",
458 "--log-level",
459 "info",
460 "list",
461 "-f",
462 "a.pna",
463 ]);
464 assert!(result.is_err());
465 }
466
467 #[test]
468 fn verbose_and_log_level_conflict() {
469 let result = Cli::try_parse_from([
470 "pna",
471 "--verbose",
472 "--log-level",
473 "info",
474 "list",
475 "-f",
476 "a.pna",
477 ]);
478 assert!(result.is_err());
479 }
480
481 #[test]
482 fn quiet_and_verbose_conflict() {
483 let result = Cli::try_parse_from(["pna", "--quiet", "--verbose", "list", "-f", "a.pna"]);
484 assert!(result.is_err());
485 }
486
487 #[test]
488 fn quiet_alone_accepted() {
489 let cli = Cli::try_parse_from(["pna", "--quiet", "list", "-f", "a.pna"]).unwrap();
490 assert_eq!(cli.global.verbosity.log_level_filter(), LevelFilter::Off);
491 }
492
493 #[test]
494 fn verbose_alone_accepted() {
495 let cli = Cli::try_parse_from(["pna", "--verbose", "list", "-f", "a.pna"]).unwrap();
496 assert_eq!(cli.global.verbosity.log_level_filter(), LevelFilter::Debug);
497 }
498
499 #[test]
500 fn log_level_alone_accepted() {
501 let cli =
502 Cli::try_parse_from(["pna", "--log-level", "debug", "list", "-f", "a.pna"]).unwrap();
503 assert_eq!(cli.global.verbosity.log_level_filter(), LevelFilter::Debug);
504 }
505}