Skip to main content

portable_network_archive/
cli.rs

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/// Runtime context for command execution.
60///
61/// This struct contains [`GlobalArgs`] and adds computed values that must be
62/// initialized early in the process lifecycle (before spawning threads).
63///
64/// # Thread Safety
65///
66/// `GlobalContext` should be created before any parallel processing.
67/// The umask is captured at construction time to minimize exposure to the
68/// inherent race window in umask reading.
69#[derive(Debug)]
70pub(crate) struct GlobalContext {
71    args: GlobalArgs,
72    umask: Umask,
73    is_root: bool,
74}
75
76impl GlobalContext {
77    /// Creates a new execution context, capturing runtime values.
78    ///
79    /// This MUST be called before spawning any threads that may create files,
80    /// as umask reading involves a brief race window.
81    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    /// Returns the cached umask value.
90    #[inline]
91    pub(crate) fn umask(&self) -> Umask {
92        self.umask
93    }
94
95    /// Returns whether the process is running as root/Administrator.
96    #[inline]
97    pub(crate) fn is_root(&self) -> bool {
98        self.is_root
99    }
100
101    /// Returns the color choice setting.
102    #[inline]
103    pub(crate) fn color(&self) -> ColorChoice {
104        self.args.color()
105    }
106
107    /// Returns whether unstable features are enabled.
108    #[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// Archive related args for compatibility with optional and positional arguments.
221// This is a temporary measure while the compatibility feature is available,
222// and will be removed once the compatibility feature is no longer available.
223//
224// NOTE: Do NOT use doc comments (///) here as they become `long_about` in clap
225// and propagate to commands that flatten this struct, causing incorrect documentation.
226#[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}