use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::shells::Shell;
use clap_verbosity_flag::{Verbosity, WarnLevel};
use std::sync::LazyLock;
#[cfg(feature = "version")]
use shadow_rs::shadow;
#[cfg(feature = "version")]
shadow!(build);
#[derive(Parser, Clone, Debug)]
#[cfg_attr(
feature = "version",
command(version = format!(
"{} ({} {}), {}",
build::PKG_VERSION,
build::SHORT_COMMIT,
build::BUILD_TARGET,
build::RUST_VERSION
))
)]
#[cfg_attr(not(feature = "version"), command(version))]
#[command(
name = "s7cmd",
about = "Reliable, flexible, and fast command-line tool for Amazon S3",
arg_required_else_help = true,
// clap 4.6 has no per-subcommand help_heading in derive, so the grouped
// listing below is static. The order/names must match the `Cmd` enum
// variants — `tests/cli_help.rs::top_level_help_*` exercise the basics,
// but adding/renaming a subcommand requires a manual edit here.
help_template = "\
{about-with-newline}
{usage-heading} {usage}
Object Operations:
ls List S3 objects
cp Copy objects from/to S3 (or S3 to S3)
mv Move objects from/to S3 (copy then delete source)
rename Rename an S3 object within an Express One Zone bucket
rm Delete a single S3 object
restore-object Restore an archived S3 object
presign Generate a pre-signed URL for an S3 object (GET only)
sync Synchronize files between local and S3 (or S3 to S3)
clean Bulk-delete S3 objects
Object Metadata:
head-object Head an S3 object
get-object-tagging Get an S3 object's tagging
put-object-tagging Put tagging on an S3 object
delete-object-tagging Delete tagging from an S3 object
Object Annotation:
get-object-annotation Download a named annotation payload from an S3 object
put-object-annotation Attach a named annotation payload to an S3 object
delete-object-annotation Delete a named annotation from an S3 object
list-object-annotations List the annotations of an S3 object and print them as JSON
Bucket Operations:
create-bucket Create an S3 bucket
delete-bucket Delete an S3 bucket
head-bucket Head an S3 bucket
Bucket Tagging:
get-bucket-tagging Get a bucket's tagging
put-bucket-tagging Put tagging on a bucket
delete-bucket-tagging Delete tagging from a bucket
Bucket Policy:
get-bucket-policy Get a bucket's policy
put-bucket-policy Put a bucket policy
delete-bucket-policy Delete a bucket's policy
Bucket Versioning:
get-bucket-versioning Get a bucket's versioning configuration
put-bucket-versioning Put a bucket versioning configuration
Bucket Lifecycle:
get-bucket-lifecycle-configuration Get a bucket's lifecycle configuration
put-bucket-lifecycle-configuration Put a bucket lifecycle configuration
delete-bucket-lifecycle-configuration Delete a bucket's lifecycle configuration
Bucket Encryption:
get-bucket-encryption Get a bucket's encryption configuration
put-bucket-encryption Put a bucket encryption configuration
delete-bucket-encryption Delete a bucket's encryption configuration
Bucket CORS:
get-bucket-cors Get a bucket's CORS configuration
put-bucket-cors Put a bucket CORS configuration
delete-bucket-cors Delete a bucket's CORS configuration
Bucket Public Access Block:
get-public-access-block Get a bucket's public access block configuration
put-public-access-block Put a bucket public access block configuration
delete-public-access-block Delete a bucket's public access block configuration
Bucket Website:
get-bucket-website Get a bucket's website configuration
put-bucket-website Put a bucket website configuration
delete-bucket-website Delete a bucket's website configuration
Bucket Logging:
get-bucket-logging Get a bucket's logging configuration
put-bucket-logging Put a bucket logging configuration
Bucket Notification:
get-bucket-notification-configuration Get a bucket's notification configuration
put-bucket-notification-configuration Put a bucket notification configuration
Bucket Replication:
get-bucket-replication Get a bucket's replication configuration
put-bucket-replication Put a bucket replication configuration
delete-bucket-replication Delete a bucket's replication configuration
Bucket Transfer Acceleration:
get-bucket-accelerate-configuration Get a bucket's transfer acceleration configuration
put-bucket-accelerate-configuration Put a bucket transfer acceleration configuration
Bucket Request Payment:
get-bucket-request-payment Get a bucket's request payment configuration
put-bucket-request-payment Put a bucket request payment configuration
Bucket Policy Status:
get-bucket-policy-status Get a bucket's policy status (whether it is public)
Batch:
batch-run Run s7cmd commands from a file (or - for stdin)
Other:
help Print this message or the help of the given subcommand(s)
Options:
{options}{after-help}",
)]
pub struct Cli {
#[arg(long, value_enum, value_name = "SHELL", global = false)]
pub auto_complete_shell: Option<Shell>,
#[command(subcommand)]
pub command: Option<Cmd>,
}
pub(crate) const MAX_PARALLEL: usize = 1024;
fn parse_parallel(s: &str) -> Result<usize, String> {
let n: usize = s
.parse()
.map_err(|_| format!("invalid value '{s}': expected an integer in 0..={MAX_PARALLEL}"))?;
if n > MAX_PARALLEL {
return Err(format!("--parallel must be no more than {MAX_PARALLEL}"));
}
Ok(n)
}
#[derive(clap::Args, Clone, Debug)]
pub struct BatchRunArgs {
#[arg(value_name = "FILE")]
pub script: String,
#[arg(long, default_value_t = 1, value_name = "N", value_parser = parse_parallel)]
pub parallel: usize,
#[arg(long)]
pub streaming: bool,
#[arg(long, conflicts_with_all = ["max_errors", "continue_on_warning"])]
pub continue_on_error: bool,
#[arg(long, conflicts_with = "continue_on_error")]
pub continue_on_warning: bool,
#[arg(
long,
value_name = "N",
value_parser = clap::value_parser!(u64).range(1..),
)]
pub max_errors: Option<u64>,
#[arg(long)]
pub no_summary: bool,
#[arg(long)]
pub no_progress: bool,
#[arg(long)]
pub check_format: bool,
#[arg(long, default_value_t = false, help_heading = "Tracing/Logging")]
pub json_tracing: bool,
#[arg(long, default_value_t = false, help_heading = "Tracing/Logging")]
pub aws_sdk_tracing: bool,
#[arg(long, default_value_t = false, help_heading = "Tracing/Logging")]
pub span_events_tracing: bool,
#[arg(long, default_value_t = false, help_heading = "Tracing/Logging")]
pub disable_color_tracing: bool,
#[command(flatten)]
pub verbosity: Verbosity<WarnLevel>,
}
#[derive(Subcommand, Clone, Debug)]
pub enum Cmd {
Ls(Box<s3ls_rs::CLIArgs>),
Cp(s3util_rs::config::args::CpArgs),
Mv(s3util_rs::config::args::MvArgs),
Rename(s3util_rs::config::args::RenameArgs),
Rm(s3util_rs::config::args::RmArgs),
RestoreObject(s3util_rs::config::args::RestoreObjectArgs),
Presign(s3util_rs::config::args::PresignArgs),
Sync(Box<s3sync::CLIArgs>),
Clean(Box<s3rm_rs::CLIArgs>),
HeadObject(s3util_rs::config::args::HeadObjectArgs),
GetObjectTagging(s3util_rs::config::args::GetObjectTaggingArgs),
PutObjectTagging(s3util_rs::config::args::PutObjectTaggingArgs),
DeleteObjectTagging(s3util_rs::config::args::DeleteObjectTaggingArgs),
GetObjectAnnotation(s3util_rs::config::args::GetObjectAnnotationArgs),
PutObjectAnnotation(s3util_rs::config::args::PutObjectAnnotationArgs),
DeleteObjectAnnotation(s3util_rs::config::args::DeleteObjectAnnotationArgs),
ListObjectAnnotations(s3util_rs::config::args::ListObjectAnnotationsArgs),
CreateBucket(s3util_rs::config::args::CreateBucketArgs),
DeleteBucket(s3util_rs::config::args::DeleteBucketArgs),
HeadBucket(s3util_rs::config::args::HeadBucketArgs),
GetBucketTagging(s3util_rs::config::args::GetBucketTaggingArgs),
PutBucketTagging(s3util_rs::config::args::PutBucketTaggingArgs),
DeleteBucketTagging(s3util_rs::config::args::DeleteBucketTaggingArgs),
GetBucketPolicy(s3util_rs::config::args::GetBucketPolicyArgs),
PutBucketPolicy(s3util_rs::config::args::PutBucketPolicyArgs),
DeleteBucketPolicy(s3util_rs::config::args::DeleteBucketPolicyArgs),
GetBucketVersioning(s3util_rs::config::args::GetBucketVersioningArgs),
PutBucketVersioning(s3util_rs::config::args::PutBucketVersioningArgs),
GetBucketLifecycleConfiguration(s3util_rs::config::args::GetBucketLifecycleConfigurationArgs),
PutBucketLifecycleConfiguration(s3util_rs::config::args::PutBucketLifecycleConfigurationArgs),
DeleteBucketLifecycleConfiguration(
s3util_rs::config::args::DeleteBucketLifecycleConfigurationArgs,
),
GetBucketEncryption(s3util_rs::config::args::GetBucketEncryptionArgs),
PutBucketEncryption(s3util_rs::config::args::PutBucketEncryptionArgs),
DeleteBucketEncryption(s3util_rs::config::args::DeleteBucketEncryptionArgs),
GetBucketCors(s3util_rs::config::args::GetBucketCorsArgs),
PutBucketCors(s3util_rs::config::args::PutBucketCorsArgs),
DeleteBucketCors(s3util_rs::config::args::DeleteBucketCorsArgs),
GetPublicAccessBlock(s3util_rs::config::args::GetPublicAccessBlockArgs),
PutPublicAccessBlock(s3util_rs::config::args::PutPublicAccessBlockArgs),
DeletePublicAccessBlock(s3util_rs::config::args::DeletePublicAccessBlockArgs),
GetBucketWebsite(s3util_rs::config::args::GetBucketWebsiteArgs),
PutBucketWebsite(s3util_rs::config::args::PutBucketWebsiteArgs),
DeleteBucketWebsite(s3util_rs::config::args::DeleteBucketWebsiteArgs),
GetBucketLogging(s3util_rs::config::args::GetBucketLoggingArgs),
PutBucketLogging(s3util_rs::config::args::PutBucketLoggingArgs),
GetBucketNotificationConfiguration(
s3util_rs::config::args::GetBucketNotificationConfigurationArgs,
),
PutBucketNotificationConfiguration(
s3util_rs::config::args::PutBucketNotificationConfigurationArgs,
),
GetBucketReplication(s3util_rs::config::args::GetBucketReplicationArgs),
PutBucketReplication(s3util_rs::config::args::PutBucketReplicationArgs),
DeleteBucketReplication(s3util_rs::config::args::DeleteBucketReplicationArgs),
GetBucketAccelerateConfiguration(s3util_rs::config::args::GetBucketAccelerateConfigurationArgs),
PutBucketAccelerateConfiguration(s3util_rs::config::args::PutBucketAccelerateConfigurationArgs),
GetBucketRequestPayment(s3util_rs::config::args::GetBucketRequestPaymentArgs),
PutBucketRequestPayment(s3util_rs::config::args::PutBucketRequestPaymentArgs),
GetBucketPolicyStatus(s3util_rs::config::args::GetBucketPolicyStatusArgs),
BatchRun(BatchRunArgs),
}
#[allow(dead_code)] pub fn cli_command() -> clap::Command {
static CACHED: LazyLock<clap::Command> = LazyLock::new(build_cli_command);
CACHED.clone()
}
fn build_cli_command() -> clap::Command {
let mut cmd = Cli::command().max_term_width(100);
let names: Vec<String> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
for name in names {
cmd = cmd.mut_subcommand(name, |sub| {
let has_flag = sub
.get_arguments()
.any(|a| a.get_id().as_str() == "auto_complete_shell");
let sub = if has_flag {
sub.mut_arg("auto_complete_shell", |a| {
a.hide(true)
.long(None::<&'static str>)
.env(None::<&'static str>)
})
} else {
sub
};
hide_credential_env_values(sub)
});
}
cmd
}
fn hide_credential_env_values(mut sub: clap::Command) -> clap::Command {
let sensitive_ids: Vec<clap::Id> = sub
.get_arguments()
.filter(|a| {
let id = a.get_id().as_str();
(id.contains("access_key") || id.contains("session_token") || id.contains("sse_c_key"))
&& a.get_env().is_some()
})
.map(|a| a.get_id().clone())
.collect();
for id in sensitive_ids {
sub = sub.mut_arg(id, |a| a.hide_env_values(true));
}
sub
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_command_repeat_calls_parse_identically() {
let argv = ["s7cmd", "head-bucket", "s3://b"];
let m1 = cli_command().try_get_matches_from(argv).unwrap();
let m2 = cli_command().try_get_matches_from(argv).unwrap();
assert_eq!(m1.subcommand_name(), Some("head-bucket"));
assert_eq!(m2.subcommand_name(), Some("head-bucket"));
}
#[test]
fn cli_command_clones_are_independent_across_calls() {
let err = cli_command()
.try_get_matches_from(["s7cmd", "no-such-command"])
.unwrap_err();
assert!(err.kind() == clap::error::ErrorKind::InvalidSubcommand);
let m = cli_command()
.try_get_matches_from(["s7cmd", "head-bucket", "s3://b"])
.unwrap();
assert_eq!(m.subcommand_name(), Some("head-bucket"));
}
#[test]
fn cli_command_concurrent_calls_succeed() {
let argv: [&str; 3] = ["s7cmd", "head-bucket", "s3://b"];
std::thread::scope(|s| {
let handles: Vec<_> = (0..16)
.map(|_| {
s.spawn(move || {
let m = cli_command().try_get_matches_from(argv).unwrap();
assert_eq!(m.subcommand_name(), Some("head-bucket"));
})
})
.collect();
for h in handles {
h.join().expect("worker panicked");
}
});
}
#[test]
fn cli_command_subcommand_auto_complete_shell_long_form_is_cleared() {
let cmd = cli_command();
for name in ["cp", "mv", "sync", "head-bucket"] {
let sub = cmd
.find_subcommand(name)
.unwrap_or_else(|| panic!("subcommand `{name}` missing"));
let arg = sub
.get_arguments()
.find(|a| a.get_id().as_str() == "auto_complete_shell")
.unwrap_or_else(|| panic!("`{name}` lost its `auto_complete_shell` arg"));
assert!(
arg.get_long().is_none(),
"`{name}` still exposes a long form for --auto-complete-shell",
);
assert!(
arg.is_hide_set(),
"`{name}` does not hide --auto-complete-shell from --help",
);
}
}
#[test]
fn cli_command_top_level_auto_complete_shell_long_form_preserved() {
let cmd = cli_command();
let arg = cmd
.get_arguments()
.find(|a| a.get_id().as_str() == "auto_complete_shell")
.expect("top-level --auto-complete-shell is missing");
assert_eq!(arg.get_long(), Some("auto-complete-shell"));
}
#[test]
fn credential_args_hide_env_values_in_every_subcommand() {
fn check(cmd: &clap::Command, path: &str, checked: &mut usize) {
for arg in cmd.get_arguments() {
let id = arg.get_id().as_str();
let sensitive = id.contains("access_key")
|| id.contains("session_token")
|| id.contains("sse_c_key");
if sensitive && arg.get_env().is_some() {
*checked += 1;
assert!(
arg.is_hide_env_values_set() || arg.is_hide_env_set(),
"{path} --{id}: env-backed credential arg must hide its env value in help output"
);
}
}
for sub in cmd.get_subcommands() {
check(sub, &format!("{path} {}", sub.get_name()), checked);
}
}
let cmd = cli_command();
let mut checked = 0;
check(&cmd, cmd.get_name(), &mut checked);
assert!(checked >= 100, "only {checked} credential args found");
}
#[test]
fn credential_args_hidden_for_each_upstream_parser() {
let cmd = cli_command();
for (sub_name, arg_id) in [
("sync", "source_access_key"),
("sync", "target_sse_c_key"),
("sync", "target_sse_c_key_md5"),
("clean", "target_secret_access_key"),
("ls", "target_session_token"),
("cp", "source_sse_c_key"),
("mv", "target_access_key"),
("rename", "source_session_token"),
("head-object", "source_sse_c_key_md5"),
] {
let sub = cmd
.find_subcommand(sub_name)
.unwrap_or_else(|| panic!("subcommand `{sub_name}` missing"));
let arg = sub
.get_arguments()
.find(|a| a.get_id().as_str() == arg_id)
.unwrap_or_else(|| panic!("`{sub_name}` lost its `{arg_id}` arg"));
assert!(
arg.is_hide_env_values_set(),
"`{sub_name} --{arg_id}` must hide its env value in help output",
);
}
}
#[test]
fn parse_parallel_accepts_zero_one_and_max() {
assert_eq!(parse_parallel("0"), Ok(0));
assert_eq!(parse_parallel("1"), Ok(1));
assert_eq!(parse_parallel(&MAX_PARALLEL.to_string()), Ok(MAX_PARALLEL));
}
#[test]
fn parse_parallel_rejects_just_over_max() {
let err = parse_parallel(&(MAX_PARALLEL + 1).to_string()).unwrap_err();
assert!(err.contains("no more than"), "got: {err}");
}
#[test]
fn parse_parallel_rejects_semaphore_panic_range() {
let boundary = (usize::MAX >> 3).wrapping_add(1);
assert!(parse_parallel(&boundary.to_string()).is_err());
assert!(parse_parallel(&usize::MAX.to_string()).is_err());
}
#[test]
fn parse_parallel_rejects_overflow_and_non_numeric() {
assert!(parse_parallel("99999999999999999999999999").is_err());
assert!(parse_parallel("abc").is_err());
assert!(parse_parallel("").is_err());
assert!(parse_parallel("-1").is_err());
}
}