use camino::{Utf8Path, Utf8PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
#[derive(Debug, clap::Args, Clone, Default)]
pub struct PasswordArgs {
#[arg(long, conflicts_with_all = ["password_file", "password"])]
pub password_stdin: bool,
#[arg(long, value_name = "PATH", conflicts_with_all = ["password_stdin", "password"])]
pub password_file: Option<Utf8PathBuf>,
#[arg(long, value_name = "STRING", conflicts_with_all = ["password_stdin", "password_file"])]
pub password: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
name = "rz",
version,
about = "Multi-format compression and decompression tool"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[arg(short, long, global = true, conflicts_with = "quiet")]
pub progress: bool,
#[arg(short, long, global = true, conflicts_with = "quiet")]
pub verbose: bool,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(long, value_name = "N", global = true)]
pub threads: Option<usize>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(alias = "c")]
Compress {
#[arg(required_unless_present = "files_from")]
input: Vec<Utf8PathBuf>,
#[arg(short, long)]
output: Option<Utf8PathBuf>,
#[arg(short, long)]
format: Option<Format>,
#[arg(short, long, conflicts_with = "store")]
level: Option<u32>,
#[arg(short = '0', long)]
store: bool,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
exclude_from: Vec<Utf8PathBuf>,
#[arg(short = 'T', long)]
files_from: Option<Utf8PathBuf>,
#[arg(long)]
exclude_vcs: bool,
#[arg(long)]
exclude_backups: bool,
#[arg(short = 'H', long)]
follow_symlinks: bool,
#[arg(long)]
totals: bool,
#[arg(long)]
exclude_vcs_ignores: bool,
#[arg(long)]
no_recursion: bool,
#[arg(short = 'n', long)]
dry_run: bool,
#[arg(long)]
mtime: Option<u64>,
#[arg(long)]
owner: Option<u64>,
#[arg(long)]
group: Option<u64>,
#[arg(long, value_parser = parse_octal_mode)]
mode: Option<u32>,
#[arg(long, value_name = "DATE", value_parser = parse_date)]
newer_than: Option<i64>,
#[arg(long, value_name = "DATE", value_parser = parse_date)]
older_than: Option<i64>,
#[arg(long)]
ignore_failed_read: bool,
#[command(flatten)]
password_args: PasswordArgs,
},
#[command(alias = "d")]
Decompress {
input: Option<Utf8PathBuf>,
#[arg(short, long)]
output: Option<Utf8PathBuf>,
#[arg(short = 't', long, conflicts_with_all = ["output", "to_stdout", "no_directory"])]
one_top_level: bool,
#[arg(short, long)]
format: Option<Format>,
#[arg(short = 'F', long, conflicts_with_all = ["no_overwrite", "keep_newer"])]
force: bool,
#[arg(long, conflicts_with = "keep_newer")]
no_overwrite: bool,
#[arg(short = 'u', long)]
keep_newer: bool,
#[arg(short = 'j', long)]
no_directory: bool,
#[arg(short = 'O', long, conflicts_with = "output")]
to_stdout: bool,
#[arg(long, default_value_t = 0)]
strip_components: u32,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
exclude_from: Vec<Utf8PathBuf>,
#[arg(long)]
include: Vec<String>,
#[arg(long)]
totals: bool,
#[arg(long, conflicts_with_all = ["force", "no_overwrite", "keep_newer"])]
backup: bool,
#[arg(long, conflicts_with_all = ["force", "no_overwrite", "keep_newer"])]
suffix: Option<String>,
#[arg(short = 'P', long)]
preserve_permissions: bool,
#[arg(long, visible_alias = "numeric-owner")]
same_owner: bool,
#[arg(long, value_name = "DATE", value_parser = parse_date)]
newer_than: Option<i64>,
#[arg(long, value_name = "DATE", value_parser = parse_date)]
older_than: Option<i64>,
#[arg(short = 'n', long)]
dry_run: bool,
#[arg(long, value_name = "OLD=NEW", value_parser = parse_rename)]
rename: Vec<(String, String)>,
#[arg(long, value_name = "PATH", value_parser = parse_prefix)]
prefix: Option<Utf8PathBuf>,
paths: Vec<String>,
#[command(flatten)]
password_args: PasswordArgs,
},
#[command(alias = "ls")]
List {
input: Option<Utf8PathBuf>,
#[arg(short, long)]
format: Option<Format>,
#[arg(short, long)]
long: bool,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
exclude_from: Vec<Utf8PathBuf>,
#[arg(long)]
sort: Option<SortField>,
#[arg(long)]
human_readable: bool,
#[arg(long)]
json: bool,
#[command(flatten)]
password_args: PasswordArgs,
},
#[command(alias = "t")]
Test {
input: Option<Utf8PathBuf>,
#[arg(short, long)]
format: Option<Format>,
#[command(flatten)]
password_args: PasswordArgs,
},
Info {
input: Option<Utf8PathBuf>,
#[arg(short, long)]
format: Option<Format>,
#[arg(long)]
human_readable: bool,
#[arg(long)]
json: bool,
#[command(flatten)]
password_args: PasswordArgs,
},
Formats {
#[arg(long)]
json: bool,
},
Completions {
shell: Shell,
},
Man,
#[command(alias = "r")]
Append {
archive: Utf8PathBuf,
#[arg(required = true)]
input: Vec<Utf8PathBuf>,
#[arg(short, long)]
format: Option<Format>,
#[arg(short, long)]
level: Option<u32>,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
exclude_from: Vec<Utf8PathBuf>,
#[arg(short = 'H', long)]
follow_symlinks: bool,
},
#[command(alias = "u")]
Update {
archive: Utf8PathBuf,
#[arg(required = true)]
input: Vec<Utf8PathBuf>,
#[arg(short, long)]
format: Option<Format>,
#[arg(short, long)]
level: Option<u32>,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
exclude_from: Vec<Utf8PathBuf>,
#[arg(short = 'H', long)]
follow_symlinks: bool,
},
#[command(alias = "cv")]
Convert {
input: Utf8PathBuf,
#[arg(short, long)]
output: Option<Utf8PathBuf>,
#[arg(long)]
from: Option<Format>,
#[arg(long)]
to: Option<Format>,
#[arg(short, long)]
level: Option<u32>,
#[arg(short = 'F', long)]
force: bool,
},
#[command(alias = "rm")]
Remove {
archive: Utf8PathBuf,
#[arg(required = true)]
patterns: Vec<String>,
#[arg(short, long)]
format: Option<Format>,
#[arg(short, long)]
level: Option<u32>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
Zip,
Tar,
#[value(alias = "tar.gz", alias = "tgz")]
TarGz,
#[value(alias = "tar.zst", alias = "tzst")]
TarZst,
#[value(alias = "tar.xz", alias = "txz")]
TarXz,
#[value(alias = "tar.bz2", alias = "tbz2")]
TarBz2,
#[value(name = "7z", alias = "seven-z")]
SevenZ,
}
#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
pub enum SortField {
Name,
Size,
Date,
}
pub fn parse_date(s: &str) -> std::result::Result<i64, String> {
if let Some(rest) = s.strip_prefix('@') {
return rest
.parse::<i64>()
.map_err(|e| format!("invalid unix timestamp `{s}`: {e}"));
}
if let Ok(dt) = time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) {
return Ok(dt.unix_timestamp());
}
let date_fmt = time::macros::format_description!("[year]-[month]-[day]");
if let Ok(date) = time::Date::parse(s, date_fmt) {
let dt = date.with_hms(0, 0, 0).map_err(|e| e.to_string())?;
return Ok(dt.assume_utc().unix_timestamp());
}
Err(format!(
"invalid date `{s}` (expected RFC 3339 like \
`2024-01-02T03:04:05Z`, a date `2024-01-02`, or `@<unix-seconds>`)"
))
}
fn parse_octal_mode(s: &str) -> std::result::Result<u32, String> {
let stripped = s
.strip_prefix("0o")
.or_else(|| s.strip_prefix("0O"))
.unwrap_or(s);
let mode =
u32::from_str_radix(stripped, 8).map_err(|e| format!("invalid octal mode `{s}`: {e}"))?;
if mode & !0o7777 != 0 {
return Err(format!(
"mode `{s}` has bits outside the 12-bit permission range (max 7777)"
));
}
Ok(mode)
}
pub fn parse_rename(s: &str) -> std::result::Result<(String, String), String> {
let (old, new) = s
.split_once('=')
.ok_or_else(|| format!("invalid --rename `{s}` (expected OLD=NEW)"))?;
if old.is_empty() {
return Err(format!("--rename `{s}`: OLD must not be empty"));
}
Ok((old.to_owned(), new.to_owned()))
}
pub fn parse_prefix(s: &str) -> std::result::Result<Utf8PathBuf, String> {
for component in Utf8Path::new(s).components() {
if matches!(component, camino::Utf8Component::ParentDir) {
return Err(format!("--prefix `{s}` contains `..` components"));
}
}
if Utf8Path::new(s).is_absolute() {
return Err(format!("--prefix `{s}` must be a relative path"));
}
Ok(Utf8PathBuf::from(s))
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
#[test]
fn threads_flag_parses() -> TestResult {
let cli = Cli::try_parse_from(["rz", "--threads", "4", "compress", "."])?;
assert_eq!(cli.threads, Some(4));
Ok(())
}
#[test]
fn threads_zero_parses() -> TestResult {
let cli = Cli::try_parse_from(["rz", "--threads", "0", "compress", "."])?;
assert_eq!(cli.threads, Some(0));
Ok(())
}
#[test]
fn threads_absent_is_none() -> TestResult {
let cli = Cli::try_parse_from(["rz", "compress", "."])?;
assert_eq!(cli.threads, None);
Ok(())
}
#[test]
fn threads_after_subcommand_parses() -> TestResult {
let cli = Cli::try_parse_from(["rz", "compress", "--threads", "2", "."])?;
assert_eq!(cli.threads, Some(2));
Ok(())
}
#[test]
fn to_stdout_conflicts_with_output() {
let res = Cli::try_parse_from(["rz", "decompress", "a.tar", "-O", "-o", "outdir"]);
assert!(
res.is_err(),
"-O silently ignoring -o hid the fact that outdir stays empty"
);
}
#[test]
fn prefix_rejects_parent_traversal_at_parse_time() {
let res = Cli::try_parse_from(["rz", "decompress", "a.tar", "--prefix", "../escape"]);
assert!(
res.is_err(),
"--prefix with `..` must be rejected during parsing"
);
}
#[test]
fn prefix_rejects_absolute_at_parse_time() {
let res = Cli::try_parse_from(["rz", "decompress", "a.tar", "--prefix", "/abs"]);
assert!(
res.is_err(),
"an absolute --prefix must be rejected during parsing"
);
}
#[test]
fn prefix_accepts_relative() -> TestResult {
let cli = Cli::try_parse_from(["rz", "decompress", "a.tar", "--prefix", "restore/v2"])?;
if let Command::Decompress { prefix, .. } = cli.command {
assert_eq!(prefix, Some(Utf8PathBuf::from("restore/v2")));
} else {
return Err("expected Decompress subcommand".into());
}
Ok(())
}
}