rars-cli 0.12.3

Command-line interface for the rars RAR archive toolkit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
use clap::{Args, Parser, Subcommand, ValueEnum};
use rars::ArchiveVersion;

#[derive(Parser)]
#[command(
    name = "rars",
    version,
    about = "Pure-Rust RAR archive toolkit",
    long_about = "rars reads, writes, and repairs RAR archives across the RAR 1.3 through RAR 7.x family.",
    after_help = "Exit codes:\n  \
                  0  success\n  \
                  1  operation failed\n  \
                  2  invalid command line\n  \
                  3  password required, wrong password, or corrupt encrypted data",
    propagate_version = true,
    disable_help_subcommand = true
)]
pub(crate) struct Cli {
    /// Worker threads for parallel compression and extraction (default: all available cores)
    #[arg(long, value_name = "N", global = true, value_parser = crate::parse_thread_count)]
    pub threads: Option<usize>,
    /// Progress reporting mode for long-running operations
    #[arg(long, value_enum, default_value_t = ProgressMode::Auto, global = true)]
    pub progress: ProgressMode,
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum ProgressMode {
    Auto,
    Always,
    Never,
}

#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum Command {
    /// Display archive metadata
    Info(InfoArgs),
    /// Verify archive integrity by extracting to a sink
    Test(TestArgs),
    /// Extract archive contents
    #[command(visible_alias = "x")]
    Extract(ExtractArgs),
    /// Repair a damaged archive using its recovery record
    Repair(RepairArgs),
    /// Create a new archive
    #[command(visible_alias = "a")]
    Add(AddArgs),
}

#[derive(Args)]
pub(crate) struct PasswordArgs {
    /// Archive password (visible in process list — prefer --password-file or the TTY prompt)
    #[arg(short = 'p', long, value_name = "PASSWORD")]
    pub password: Option<String>,
    /// Read password from file ("-" for stdin); trailing newlines are stripped
    #[arg(long, value_name = "PATH", conflicts_with = "password")]
    pub password_file: Option<std::path::PathBuf>,
}

#[derive(Args, Default)]
pub(crate) struct ReadOptionsArgs {
    /// Source encoding for legacy names without Unicode (e.g. cp850)
    #[arg(long, value_name = "ENCODING")]
    pub legacy_name_encoding: Option<rars::filename::LegacyNameEncoding>,
    /// Maximum RAR 5 member size to decode in memory; larger ones stream (e.g. 512m, 1g)
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub rar50_buffered_decode_limit: Option<usize>,
    /// Maximum declared RAR5/7 dictionary size (not total memory)
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub rar50_dictionary_size_limit: Option<usize>,
    /// Maximum decoded output per logical member or archive comment
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub max_member_output_bytes: Option<usize>,
    /// Maximum decoded output per extraction/test call, or per archive comment
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub max_total_output_bytes: Option<usize>,
    /// Maximum aggregate reader workspace per call (not process RAM)
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub max_reader_workspace_bytes: Option<usize>,
    /// Maximum top-level headers per physical archive parse
    #[arg(long, value_name = "COUNT")]
    pub max_header_count: Option<u64>,
    /// Maximum plaintext header bytes per physical archive parse
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub max_header_bytes: Option<usize>,
    /// Directory for RAR5 filtered decoding above the buffering threshold
    #[arg(long, value_name = "PATH", requires = "rar50_scratch_bytes")]
    pub rar50_scratch_dir: Option<std::path::PathBuf>,
    /// Maximum temporary bytes for RAR5 scratch decoding
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string, requires = "rar50_scratch_dir")]
    pub rar50_scratch_bytes: Option<usize>,
    /// Maximum workspace per RAR5 scratch filter
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string, requires = "rar50_scratch_dir")]
    pub rar50_filter_memory_limit: Option<usize>,
}

impl ReadOptionsArgs {
    pub fn scratch(&self) -> Option<rars::Rar50Scratch> {
        self.rar50_scratch_dir
            .as_ref()
            .zip(self.rar50_scratch_bytes)
            .map(|(dir, bytes)| {
                let policy = rars::Rar50Scratch::new(dir, bytes as u64);
                match self.rar50_filter_memory_limit {
                    Some(limit) => policy.with_filter_memory_limit(limit as u64),
                    None => policy,
                }
            })
    }
    pub fn options<'a>(
        &self,
        password: Option<&'a [u8]>,
        scratch: Option<&'a rars::Rar50Scratch>,
    ) -> rars::ArchiveReadOptions<'a> {
        let mut options = rars::ArchiveReadOptions::with_optional_password(password);
        options.legacy_name_encoding = self.legacy_name_encoding;
        options.max_header_count = self.max_header_count;
        options.max_header_bytes = self.max_header_bytes.map(|value| value as u64);
        options.max_member_output_bytes = self.max_member_output_bytes.map(|value| value as u64);
        options.max_total_output_bytes = self.max_total_output_bytes.map(|value| value as u64);
        options.max_reader_workspace_bytes =
            self.max_reader_workspace_bytes.map(|value| value as u64);
        options.rar50_dictionary_size_limit =
            self.rar50_dictionary_size_limit.map(|value| value as u64);
        options.rar50_buffered_decode_limit =
            self.rar50_buffered_decode_limit.map(|value| value as u64);
        options.rar50_scratch = scratch;
        options
    }
}

#[derive(Args)]
pub(crate) struct InfoArgs {
    #[command(flatten)]
    pub read_options: ReadOptionsArgs,
    #[command(flatten)]
    pub password: PasswordArgs,
    /// Show all raw block/header fields (the developer-style dump)
    #[arg(short = 'v', long)]
    pub verbose: bool,
    /// One or more archive paths
    #[arg(value_name = "ARCHIVE", required = true)]
    pub paths: Vec<std::path::PathBuf>,
}

#[derive(Args)]
pub(crate) struct TestArgs {
    #[command(flatten)]
    pub password: PasswordArgs,
    #[command(flatten)]
    pub read_options: ReadOptionsArgs,
    /// Archive path (first volume of a multi-part set), optionally followed by sibling parts
    #[arg(value_name = "ARCHIVE", required = true)]
    pub paths: Vec<std::path::PathBuf>,
}

#[derive(Args)]
pub(crate) struct ExtractArgs {
    #[command(flatten)]
    pub password: PasswordArgs,
    #[command(flatten)]
    pub read_options: ReadOptionsArgs,
    /// Behaviour when an extracted file already exists on disk
    #[arg(long, value_enum, default_value_t = OverwriteCli::Never)]
    pub overwrite: OverwriteCli,
    /// Archive (and optional sibling parts) followed by an output directory
    #[arg(value_name = "PATH", required = true, num_args = 2..)]
    pub paths: Vec<std::path::PathBuf>,
}

#[derive(Args)]
pub(crate) struct RepairArgs {
    #[command(flatten)]
    pub password: PasswordArgs,
    /// Either <archive> <repaired-archive>, or <rar-parts-and-rev-files...> <outdir>
    #[arg(value_name = "PATH", required = true, num_args = 2..)]
    pub paths: Vec<std::path::PathBuf>,
}

#[derive(Args)]
pub(crate) struct AddArgs {
    #[command(flatten)]
    pub password: PasswordArgs,
    /// Target archive format (default: rar50, the modern widely-compatible format)
    #[arg(long, value_enum, default_value_t = TargetFormat::Rar50)]
    pub format: TargetFormat,
    /// Store files without compression (equivalent to --level 0)
    #[arg(long)]
    pub store: bool,
    /// Compression level (0..5; 0 implies --store)
    #[arg(long, value_name = "LEVEL")]
    pub level: Option<u8>,
    /// Dictionary size (e.g. 4m, 128k; RAR 1.5+)
    ///
    /// Left unset, the writer fits one to the data: the smallest size that
    /// reaches across the largest member, or the whole archive when --solid
    /// shares one window. A larger dictionary can pack better and always costs
    /// time and memory to search.
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub dict_size: Option<usize>,
    /// Maximum total compression working memory (default: 256m)
    ///
    /// Left unset rather than defaulted here, so a volume set below RAR 5, which
    /// is still built in memory, can tell an explicit request from a default and
    /// refuse the one it cannot honour.
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub memory_limit: Option<usize>,
    /// Hard managed-allocation ceiling for RAR5/7, separate from estimated workspace
    #[arg(long, value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub max_memory: Option<usize>,
    /// Directory for temporary compressed payloads (RAR 5+)
    #[arg(long, value_name = "PATH")]
    pub temp_dir: Option<std::path::PathBuf>,
    /// Use solid compression (treats inputs as one continuous stream)
    #[arg(long)]
    pub solid: bool,
    /// Encrypt archive headers (RAR 3.x/4.x and RAR 5+) (requires --password)
    #[arg(long = "encrypt-headers")]
    pub encrypt_headers: bool,
    /// Emit a quick-open service block (RAR 5+ only)
    #[arg(long = "quick-open")]
    pub quick_open: bool,
    /// Archive-level comment
    #[arg(long, value_name = "TEXT")]
    pub comment: Option<String>,
    /// Archive name to embed in archive metadata service (RAR 5+)
    #[arg(long = "archive-name", value_name = "NAME")]
    pub archive_name: Option<String>,
    /// Per-file comment (not RAR 3.x/4.x)
    #[arg(long = "file-comment", value_name = "TEXT")]
    pub file_comment: Option<String>,
    /// Add a recovery record at the given percentage (1..100; RAR 5+)
    #[arg(long = "recovery-percent", value_name = "PERCENT")]
    pub recovery_percent: Option<u64>,
    /// Split archive into volumes of this size
    #[arg(long = "volume-size", value_name = "SIZE", value_parser = crate::parse_size_string)]
    pub volume_size: Option<usize>,
    /// Delta filter with the given channel count (RAR 2.9+)
    #[arg(long = "delta-filter", value_name = "CHANNELS")]
    pub delta_filter: Option<usize>,
    /// E8 x86 call filter (RAR 2.9+)
    #[arg(long = "e8-filter", conflicts_with = "e8e9_filter")]
    pub e8_filter: bool,
    /// E8E9 x86 call/jump filter (RAR 2.9+)
    #[arg(long = "e8e9-filter")]
    pub e8e9_filter: bool,
    /// Itanium filter (RAR 2.9/3.x/4.x only)
    #[arg(long = "itanium-filter")]
    pub itanium_filter: bool,
    /// RGB image filter with the given pixel width (RAR 2.9/3.x/4.x only)
    #[arg(long = "rgb-filter", value_name = "WIDTH")]
    pub rgb_filter: Option<usize>,
    /// Audio filter with the given channel count (RAR 2.9/3.x/4.x only)
    #[arg(long = "audio-filter", value_name = "CHANNELS")]
    pub audio_filter: Option<usize>,
    /// ARM filter (RAR 5+ only)
    #[arg(long = "arm-filter")]
    pub arm_filter: bool,
    /// Auto-detect data filter (RAR 5+ does this by default)
    #[arg(long = "auto-filter")]
    pub auto_filter: bool,
    /// Skip filter detection, compressing the data as it is (RAR 2.9+; the
    /// older formats never look, so the flag passes without comment there)
    #[arg(long = "no-filter", conflicts_with = "auto_filter")]
    pub no_filter: bool,
    /// Use the PPMd compression algorithm (RAR 2.9/3.x/4.x only)
    ///
    /// Cannot be combined with --auto-filter: choosing a filter means measuring
    /// candidates through LZ, which forcing PPMd leaves nothing to measure.
    #[arg(long)]
    pub ppmd: bool,
    /// Output archive path
    #[arg(value_name = "ARCHIVE")]
    pub archive: std::path::PathBuf,
    /// Files (and directories) to add to the archive
    #[arg(value_name = "FILE", required = true)]
    pub files: Vec<std::path::PathBuf>,
}

#[derive(Copy, Clone, ValueEnum)]
pub(crate) enum TargetFormat {
    Rar14,
    Rar15,
    Rar20,
    Rar29,
    Rar30,
    Rar40,
    Rar50,
    Rar70,
}

impl TargetFormat {
    pub(crate) fn archive_version(self) -> ArchiveVersion {
        match self {
            Self::Rar14 => ArchiveVersion::Rar14,
            Self::Rar15 => ArchiveVersion::Rar15,
            Self::Rar20 => ArchiveVersion::Rar20,
            Self::Rar29 => ArchiveVersion::Rar29,
            Self::Rar30 => ArchiveVersion::Rar30,
            Self::Rar40 => ArchiveVersion::Rar40,
            Self::Rar50 => ArchiveVersion::Rar50,
            Self::Rar70 => ArchiveVersion::Rar70,
        }
    }

    /// The `--format` value for a version, if the argument accepts one.
    /// ArchiveVersion::Rar13 has no spelling here, so it is never suggested.
    pub(crate) fn from_archive_version(version: ArchiveVersion) -> Option<Self> {
        match version {
            ArchiveVersion::Rar14 => Some(Self::Rar14),
            ArchiveVersion::Rar15 => Some(Self::Rar15),
            ArchiveVersion::Rar20 => Some(Self::Rar20),
            ArchiveVersion::Rar29 => Some(Self::Rar29),
            ArchiveVersion::Rar30 => Some(Self::Rar30),
            ArchiveVersion::Rar40 => Some(Self::Rar40),
            ArchiveVersion::Rar50 => Some(Self::Rar50),
            ArchiveVersion::Rar70 => Some(Self::Rar70),
            _ => None,
        }
    }
}

#[derive(Copy, Clone, ValueEnum)]
pub(crate) enum OverwriteCli {
    Never,
    Always,
}

impl From<OverwriteCli> for crate::output::OverwritePolicy {
    fn from(value: OverwriteCli) -> Self {
        match value {
            OverwriteCli::Never => Self::Never,
            OverwriteCli::Always => Self::Always,
        }
    }
}

pub(crate) fn parse() -> Cli {
    match Cli::try_parse() {
        Ok(cli) => cli,
        Err(err) => {
            use clap::error::ErrorKind;
            let message = err.to_string();
            match err.kind() {
                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
                    print!("{message}");
                    std::process::exit(0);
                }
                _ => {
                    eprint!("{message}");
                    std::process::exit(2);
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepted_formats_round_trip_through_command_line_spelling() {
        for (name, version) in [
            ("rar14", ArchiveVersion::Rar14),
            ("rar15", ArchiveVersion::Rar15),
            ("rar20", ArchiveVersion::Rar20),
            ("rar29", ArchiveVersion::Rar29),
            ("rar30", ArchiveVersion::Rar30),
            ("rar40", ArchiveVersion::Rar40),
            ("rar50", ArchiveVersion::Rar50),
            ("rar70", ArchiveVersion::Rar70),
        ] {
            let cli =
                Cli::try_parse_from(["rars", "a", "--format", name, "out.rar", "input"]).unwrap();
            let Command::Add(args) = cli.command else {
                panic!("expected add command")
            };
            assert_eq!(args.format.archive_version(), version);
            assert_eq!(
                TargetFormat::from_archive_version(version)
                    .unwrap()
                    .archive_version(),
                version
            );
        }
        assert!(TargetFormat::from_archive_version(ArchiveVersion::Rar13).is_none());
        assert!(
            Cli::try_parse_from(["rars", "a", "--format", "rar13", "out.rar", "input"]).is_err()
        );
    }

    #[test]
    fn reader_policy_forwards_all_limits_and_optional_password_without_loss() {
        let args = ReadOptionsArgs {
            rar50_buffered_decode_limit: Some(0),
            rar50_dictionary_size_limit: Some(11),
            max_member_output_bytes: Some(12),
            max_total_output_bytes: Some(13),
            max_reader_workspace_bytes: Some(14),
            max_header_count: Some(15),
            max_header_bytes: Some(16),
            rar50_scratch_dir: Some("scratch".into()),
            rar50_scratch_bytes: Some(17),
            ..Default::default()
        };
        let scratch = args.scratch().unwrap();
        let options = args.options(Some(b""), Some(&scratch));
        assert_eq!(options.password, Some(b"".as_slice()));
        assert_eq!(options.rar50_buffered_decode_limit, Some(0));
        assert_eq!(options.rar50_dictionary_size_limit, Some(11));
        assert_eq!(options.max_member_output_bytes, Some(12));
        assert_eq!(options.max_total_output_bytes, Some(13));
        assert_eq!(options.max_reader_workspace_bytes, Some(14));
        assert_eq!(options.max_header_count, Some(15));
        assert_eq!(options.max_header_bytes, Some(16));
        assert!(std::ptr::eq(options.rar50_scratch.unwrap(), &scratch));
        let defaults = ReadOptionsArgs::default();
        assert!(defaults.scratch().is_none());
        assert!(defaults.options(None, None).password.is_none());
        assert!(defaults.options(None, None).rar50_scratch.is_none());
        let with_filter_limit = ReadOptionsArgs {
            rar50_filter_memory_limit: Some(0),
            ..args
        };
        assert!(with_filter_limit.scratch().is_some());
    }

    #[test]
    fn parser_enforces_scratch_pairs_password_conflicts_and_path_counts() {
        for args in [
            vec![
                "rars",
                "test",
                "--rar50-scratch-dir",
                "scratch",
                "input.rar",
            ],
            vec!["rars", "test", "--rar50-scratch-bytes", "1m", "input.rar"],
            vec![
                "rars",
                "test",
                "--password",
                "secret",
                "--password-file",
                "password",
                "input.rar",
            ],
            vec!["rars", "x", "input.rar"],
            vec!["rars", "repair", "input.rar"],
        ] {
            assert!(Cli::try_parse_from(args).is_err());
        }
        assert!(matches!(
            crate::output::OverwritePolicy::from(OverwriteCli::Never),
            crate::output::OverwritePolicy::Never
        ));
        assert!(matches!(
            crate::output::OverwritePolicy::from(OverwriteCli::Always),
            crate::output::OverwritePolicy::Always
        ));
    }
}