diff --git a/crates/cli/src/commands/cors.rs b/crates/cli/src/commands/cors.rs
index 976869d..aa35ad6 100644
--- a/crates/cli/src/commands/cors.rs
+++ b/crates/cli/src/commands/cors.rs
@@ -534,6 +534,7 @@ mod tests {
assert!(parse_bucket_path("local").is_err());
assert!(parse_bucket_path("local/").is_err());
assert!(parse_bucket_path("/bucket").is_err());
+ assert!(parse_bucket_path("local//").is_err());
assert!(parse_bucket_path("local//bucket").is_err());
assert!(parse_bucket_path("local/my-bucket/nested").is_err());
assert!(parse_bucket_path("local///").is_err());
@@ -595,6 +596,40 @@ mod tests {
assert!(error.contains("unsupported method"));
}
+ #[test]
+ fn test_parse_cors_configuration_rejects_missing_allowed_origin() {
+ let error = parse_cors_configuration(
+ r#"{
+ "rules": [
+ {
+ "allowedOrigins": [],
+ "allowedMethods": ["GET"]
+ }
+ ]
+ }"#,
+ )
+ .expect_err("missing allowed origin");
+
+ assert!(error.contains("at least one allowed origin"));
+ }
+
+ #[test]
+ fn test_parse_cors_configuration_rejects_missing_allowed_method() {
+ let error = parse_cors_configuration(
+ r#"{
+ "rules": [
+ {
+ "allowedOrigins": ["*"],
+ "allowedMethods": []
+ }
+ ]
+ }"#,
+ )
+ .expect_err("missing allowed method");
+
+ assert!(error.contains("at least one allowed method"));
+ }
+
#[test]
fn test_parse_cors_configuration_xml_rejects_missing_allowed_origin() {
let error = parse_cors_configuration(
@@ -715,6 +750,28 @@ mod tests {
assert_eq!(config.rules[0].allowed_methods, vec!["GET".to_string()]);
}
+ #[test]
+ fn test_parse_cors_configuration_xml_drops_blank_optional_headers() {
+ let config = parse_cors_configuration(
+ r#"
+
+
+ https://console.example.com
+ get
+
+
+
+
+"#,
+ )
+ .expect("parse xml config with blank optional headers");
+
+ assert_eq!(config.rules.len(), 1);
+ assert_eq!(config.rules[0].allowed_headers, None);
+ assert_eq!(config.rules[0].expose_headers, None);
+ assert_eq!(config.rules[0].allowed_methods, vec!["GET".to_string()]);
+ }
+
#[test]
fn test_cors_input_source_prefers_positional_argument() {
let args = SetCorsArgs {
@@ -763,6 +820,56 @@ mod tests {
assert!(cors_input_source(&args).is_err());
}
+ #[tokio::test]
+ async fn test_execute_list_rejects_empty_normalized_bucket_path() {
+ let code = execute(
+ CorsArgs {
+ command: CorsCommands::List(BucketArg {
+ path: "local///".to_string(),
+ force: false,
+ }),
+ },
+ OutputConfig::default(),
+ )
+ .await;
+
+ assert_eq!(code, ExitCode::UsageError);
+ }
+
+ #[tokio::test]
+ async fn test_execute_set_rejects_empty_normalized_bucket_path_before_reading_source() {
+ let code = execute(
+ CorsArgs {
+ command: CorsCommands::Set(SetCorsArgs {
+ path: "local///".to_string(),
+ source: Some("missing-cors.json".to_string()),
+ file: None,
+ force: false,
+ }),
+ },
+ OutputConfig::default(),
+ )
+ .await;
+
+ assert_eq!(code, ExitCode::UsageError);
+ }
+
+ #[tokio::test]
+ async fn test_execute_remove_rejects_empty_normalized_bucket_path() {
+ let code = execute(
+ CorsArgs {
+ command: CorsCommands::Remove(BucketArg {
+ path: "local///".to_string(),
+ force: false,
+ }),
+ },
+ OutputConfig::default(),
+ )
+ .await;
+
+ assert_eq!(code, ExitCode::UsageError);
+ }
+
#[tokio::test]
async fn test_read_cors_source_reads_file_contents() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
diff --git a/crates/cli/src/commands/event.rs b/crates/cli/src/commands/event.rs
index 52d8601..359b985 100644
--- a/crates/cli/src/commands/event.rs
+++ b/crates/cli/src/commands/event.rs
@@ -22,6 +22,11 @@ Examples:
rc event add local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts --event delete
rc bucket event add local/my-bucket arn:aws:lambda:us-east-1:123456789012:function:thumbnail --event put,delete";
+const EVENT_REMOVE_AFTER_HELP: &str = "\
+Examples:
+ rc bucket event remove local/my-bucket arn:aws:sqs:us-east-1:123456789012:jobs
+ rc event remove local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts";
+
/// Manage bucket event notifications
#[derive(Args, Debug)]
#[command(after_help = EVENT_AFTER_HELP)]
@@ -71,6 +76,7 @@ pub struct AddArgs {
}
#[derive(Args, Debug)]
+#[command(after_help = EVENT_REMOVE_AFTER_HELP)]
pub struct RemoveArgs {
/// Path to the bucket (alias/bucket)
pub path: String,
@@ -480,6 +486,7 @@ mod tests {
assert!(parse_bucket_path("local").is_err());
assert!(parse_bucket_path("/bucket").is_err());
assert!(parse_bucket_path("local/my-bucket/prefix").is_err());
+ assert!(parse_bucket_path("local///").is_err());
}
#[test]
diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs
index 9668048..5d0ba6d 100644
--- a/crates/cli/src/commands/mod.rs
+++ b/crates/cli/src/commands/mod.rs
@@ -612,6 +612,64 @@ mod tests {
}
}
+ #[test]
+ fn cli_accepts_top_level_cors_set_with_positional_source() {
+ let cli = Cli::try_parse_from(["rc", "cors", "set", "local/my-bucket", "cors.xml"])
+ .expect("parse top-level cors set with positional source");
+
+ match cli.command {
+ Commands::Cors(cors::CorsCommands::Set(arg)) => {
+ assert_eq!(arg.path, "local/my-bucket");
+ assert_eq!(arg.source.as_deref(), Some("cors.xml"));
+ assert_eq!(arg.file, None);
+ assert!(!arg.force);
+ }
+ other => panic!("expected top-level cors set command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_top_level_cors_set_with_legacy_file_flag() {
+ let cli = Cli::try_parse_from([
+ "rc",
+ "cors",
+ "set",
+ "local/my-bucket",
+ "--file",
+ "cors.json",
+ "--force",
+ ])
+ .expect("parse top-level cors set with --file");
+
+ match cli.command {
+ Commands::Cors(cors::CorsCommands::Set(arg)) => {
+ assert_eq!(arg.path, "local/my-bucket");
+ assert_eq!(arg.source, None);
+ assert_eq!(arg.file.as_deref(), Some("cors.json"));
+ assert!(arg.force);
+ }
+ other => panic!("expected top-level cors set command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_bucket_cors_list_force_flag() {
+ let cli =
+ Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket", "--force"])
+ .expect("parse bucket cors list with force");
+
+ match cli.command {
+ Commands::Bucket(args) => match args.command {
+ bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => {
+ assert_eq!(arg.path, "local/my-bucket");
+ assert!(arg.force);
+ }
+ other => panic!("expected bucket cors list command, got {:?}", other),
+ },
+ other => panic!("expected bucket command, got {:?}", other),
+ }
+ }
+
#[test]
fn cli_accepts_bucket_lifecycle_subcommand() {
let cli = Cli::try_parse_from([
@@ -701,4 +759,251 @@ mod tests {
other => panic!("expected object command, got {:?}", other),
}
}
+
+ #[test]
+ fn cli_accepts_bucket_event_remove_subcommand() {
+ let cli = Cli::try_parse_from([
+ "rc",
+ "bucket",
+ "event",
+ "remove",
+ "local/my-bucket",
+ "arn:aws:sns:us-east-1:123456789012:alerts",
+ ])
+ .expect("parse bucket event remove");
+
+ match cli.command {
+ Commands::Bucket(args) => match args.command {
+ bucket::BucketCommands::Event(event::EventCommands::Remove(arg)) => {
+ assert_eq!(arg.path, "local/my-bucket");
+ assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts");
+ }
+ other => panic!("expected bucket event remove command, got {:?}", other),
+ },
+ other => panic!("expected bucket command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_rm_purge_flag() {
+ let cli = Cli::try_parse_from(["rc", "rm", "local/my-bucket/object.txt", "--purge"])
+ .expect("parse rm purge");
+
+ match cli.command {
+ Commands::Rm(arg) => {
+ assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
+ assert!(arg.purge);
+ }
+ other => panic!("expected rm command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_object_remove_purge_flag() {
+ let cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "remove",
+ "local/my-bucket/object.txt",
+ "--purge",
+ ])
+ .expect("parse object remove purge");
+
+ match cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Remove(arg) => {
+ assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]);
+ assert!(arg.purge);
+ }
+ other => panic!("expected object remove command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_object_stat_subcommand() {
+ let cli = Cli::try_parse_from(["rc", "object", "stat", "local/my-bucket/report.json"])
+ .expect("parse object stat");
+
+ match cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Stat(arg) => {
+ assert_eq!(arg.path, "local/my-bucket/report.json");
+ }
+ other => panic!("expected object stat command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_object_copy_with_transfer_options() {
+ let cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "copy",
+ "./report.json",
+ "local/my-bucket/reports/",
+ "--content-type",
+ "application/json",
+ "--storage-class",
+ "STANDARD_IA",
+ "--dry-run",
+ ])
+ .expect("parse object copy with transfer options");
+
+ match cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Copy(arg) => {
+ assert_eq!(arg.source, "./report.json");
+ assert_eq!(arg.target, "local/my-bucket/reports/");
+ assert_eq!(arg.content_type.as_deref(), Some("application/json"));
+ assert_eq!(arg.storage_class.as_deref(), Some("STANDARD_IA"));
+ assert!(arg.dry_run);
+ }
+ other => panic!("expected object copy command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_object_move_with_recursive_dry_run() {
+ let cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "move",
+ "local/source-bucket/logs/",
+ "local/archive-bucket/logs/",
+ "--recursive",
+ "--dry-run",
+ "--continue-on-error",
+ ])
+ .expect("parse object move with recursive dry-run");
+
+ match cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Move(arg) => {
+ assert_eq!(arg.source, "local/source-bucket/logs/");
+ assert_eq!(arg.target, "local/archive-bucket/logs/");
+ assert!(arg.recursive);
+ assert!(arg.dry_run);
+ assert!(arg.continue_on_error);
+ }
+ other => panic!("expected object move command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_object_show_and_head_options() {
+ let show_cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "show",
+ "local/my-bucket/report.json",
+ "--version-id",
+ "v1",
+ "--rewind",
+ "1h",
+ ])
+ .expect("parse object show options");
+
+ match show_cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Show(arg) => {
+ assert_eq!(arg.path, "local/my-bucket/report.json");
+ assert_eq!(arg.version_id.as_deref(), Some("v1"));
+ assert_eq!(arg.rewind.as_deref(), Some("1h"));
+ }
+ other => panic!("expected object show command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+
+ let head_cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "head",
+ "local/my-bucket/report.json",
+ "--bytes",
+ "128",
+ "--version-id",
+ "v2",
+ ])
+ .expect("parse object head options");
+
+ match head_cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Head(arg) => {
+ assert_eq!(arg.path, "local/my-bucket/report.json");
+ assert_eq!(arg.bytes, Some(128));
+ assert_eq!(arg.version_id.as_deref(), Some("v2"));
+ }
+ other => panic!("expected object head command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn cli_accepts_object_find_and_tree_options() {
+ let find_cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "find",
+ "local/my-bucket/logs/",
+ "--name",
+ "*.json",
+ "--maxdepth",
+ "2",
+ "--count",
+ "--print",
+ ])
+ .expect("parse object find options");
+
+ match find_cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Find(arg) => {
+ assert_eq!(arg.path, "local/my-bucket/logs/");
+ assert_eq!(arg.name.as_deref(), Some("*.json"));
+ assert_eq!(arg.maxdepth, 2);
+ assert!(arg.count);
+ assert!(arg.print);
+ }
+ other => panic!("expected object find command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+
+ let tree_cli = Cli::try_parse_from([
+ "rc",
+ "object",
+ "tree",
+ "local/my-bucket/logs/",
+ "--level",
+ "4",
+ "--size",
+ "--pattern",
+ "*.json",
+ "--full-path",
+ ])
+ .expect("parse object tree options");
+
+ match tree_cli.command {
+ Commands::Object(args) => match args.command {
+ object::ObjectCommands::Tree(arg) => {
+ assert_eq!(arg.path, "local/my-bucket/logs/");
+ assert_eq!(arg.level, 4);
+ assert!(arg.size);
+ assert_eq!(arg.pattern.as_deref(), Some("*.json"));
+ assert!(arg.full_path);
+ }
+ other => panic!("expected object tree command, got {:?}", other),
+ },
+ other => panic!("expected object command, got {:?}", other),
+ }
+ }
}
diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs
index bd1662b..45da61d 100644
--- a/crates/cli/tests/help_contract.rs
+++ b/crates/cli/tests/help_contract.rs
@@ -472,11 +472,25 @@ fn nested_subcommand_help_contract() {
"rc bucket event add local/my-bucket arn:aws:sqs:us-east-1:123456789012:jobs --event put",
],
},
+ HelpCase {
+ args: &["bucket", "event", "remove"],
+ usage: "Usage: rc bucket event remove [OPTIONS] ",
+ expected_tokens: &[
+ "--force",
+ "Examples:",
+ "rc event remove local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts",
+ ],
+ },
HelpCase {
args: &["bucket", "cors"],
usage: "Usage: rc bucket cors [OPTIONS] ",
expected_tokens: &["list", "set", "remove"],
},
+ HelpCase {
+ args: &["bucket", "cors", "set"],
+ usage: "Usage: rc bucket cors set [OPTIONS] [SOURCE]",
+ expected_tokens: &["--file", "--force", "read from stdin"],
+ },
HelpCase {
args: &["object", "copy"],
usage: "Usage: rc object copy [OPTIONS] ",
@@ -497,6 +511,16 @@ fn nested_subcommand_help_contract() {
usage: "Usage: rc object show [OPTIONS] ",
expected_tokens: &["--enc-key", "--rewind", "--version-id"],
},
+ HelpCase {
+ args: &["object", "stat"],
+ usage: "Usage: rc object stat [OPTIONS] ",
+ expected_tokens: &["--version-id", "--rewind"],
+ },
+ HelpCase {
+ args: &["object", "share"],
+ usage: "Usage: rc object share [OPTIONS] ",
+ expected_tokens: &["--expire", "--upload", "--content-type"],
+ },
HelpCase {
args: &["admin", "user", "info"],
usage: "Usage: rc admin user info [OPTIONS] ",
@@ -774,6 +798,15 @@ fn nested_subcommand_help_contract() {
"rc event add local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts --event delete",
],
},
+ HelpCase {
+ args: &["event", "remove"],
+ usage: "Usage: rc event remove [OPTIONS] ",
+ expected_tokens: &[
+ "--force",
+ "Examples:",
+ "rc event remove local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts",
+ ],
+ },
HelpCase {
args: &["replicate", "list"],
usage: "Usage: rc replicate list [OPTIONS] ",