Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions crates/cli/src/commands/rb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use rc_core::{AliasManager, ObjectStore as _};
use rc_s3::S3Client;
use serde::Serialize;

use super::rm::{self, RmArgs};
use crate::exit_code::ExitCode;
use crate::output::{Formatter, OutputConfig};

Expand All @@ -21,7 +22,7 @@ pub struct RbArgs {
pub force: bool,

/// Remove bucket even if it has incomplete multipart uploads
#[arg(long)]
#[arg(long, hide = true)]
pub dangerous: bool,
}

Expand All @@ -37,10 +38,10 @@ struct RbOutput {
pub async fn execute(args: RbArgs, output_config: OutputConfig) -> ExitCode {
let formatter = Formatter::new(output_config);

if args.force || args.dangerous {
if args.dangerous {
return formatter.fail(
ExitCode::UnsupportedFeature,
"--force and --dangerous are not implemented for bucket removal",
"--dangerous is not implemented for bucket removal",
);
}

Expand Down Expand Up @@ -92,6 +93,25 @@ pub async fn execute(args: RbArgs, output_config: OutputConfig) -> ExitCode {
}
}

if args.force {
let rm_args = RmArgs {
paths: vec![format!("{alias_name}/{bucket}")],
recursive: true,
force: true,
dry_run: false,
incomplete: false,
versions: true,
bypass: false,
purge: true,
};

if let Err((code, _)) =
rm::delete_recursive(&client, &alias_name, &bucket, "", &rm_args, &formatter).await
{
return code;
}
}

// Delete the bucket
match client.delete_bucket(&bucket).await {
Ok(()) => {
Expand Down
193 changes: 144 additions & 49 deletions crates/cli/src/commands/rm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub struct RmArgs {
pub dry_run: bool,

/// Remove incomplete multipart uploads older than specified duration
#[arg(long)]
#[arg(long, hide = true)]
pub incomplete: bool,

/// Include versions (requires versioning support)
Expand Down Expand Up @@ -67,10 +67,10 @@ struct RmOutput {
pub async fn execute(args: RmArgs, output_config: OutputConfig) -> ExitCode {
let formatter = Formatter::new(output_config);

if args.incomplete || args.versions || args.bypass {
if args.incomplete {
return formatter.fail(
ExitCode::UnsupportedFeature,
"--incomplete, --versions, and --bypass are not implemented; refusing to continue with a silently ignored destructive option",
"--incomplete is not implemented",
);
}

Expand Down Expand Up @@ -244,7 +244,7 @@ async fn delete_single(
}
}

async fn delete_recursive(
pub(crate) async fn delete_recursive(
client: &S3Client,
alias_name: &str,
bucket: &str,
Expand All @@ -254,50 +254,11 @@ async fn delete_recursive(
) -> Result<Vec<String>, (ExitCode, Vec<String>)> {
let path = RemotePath::new(alias_name, bucket, prefix);

// Collect all objects to delete
let mut keys_to_delete = Vec::new();
let mut continuation_token: Option<String> = None;

loop {
let options = ListOptions {
recursive: true,
max_keys: Some(1000),
continuation_token: continuation_token.clone(),
..Default::default()
};

match client.list_objects(&path, options).await {
Ok(result) => {
for item in result.items {
if !item.is_dir {
keys_to_delete.push(item.key);
}
}

if result.truncated {
continuation_token = result.continuation_token;
} else {
break;
}
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("NotFound") || err_str.contains("NoSuchBucket") {
let code = formatter.fail_with_suggestion(
ExitCode::NotFound,
&format!("Bucket not found: {bucket}"),
"Check the bucket path and retry the remove command.",
);
return Err((code, vec![]));
}
let code = formatter.fail(
ExitCode::NetworkError,
&format!("Failed to list objects: {e}"),
);
return Err((code, vec![]));
}
}
}
let keys_to_delete = if args.versions || args.purge {
list_version_keys(client, &path, bucket, formatter).await?
} else {
list_object_keys(client, &path, bucket, formatter).await?
};

if keys_to_delete.is_empty() {
if !args.force {
Expand Down Expand Up @@ -425,10 +386,110 @@ fn parse_rm_path(path: &str) -> Result<(String, String, String), String> {

fn delete_request_options(args: &RmArgs) -> DeleteRequestOptions {
DeleteRequestOptions {
force_delete: args.purge,
force_delete: args.purge || args.versions,
bypass_governance_retention: args.bypass,
}
}

async fn list_object_keys(
client: &S3Client,
path: &RemotePath,
bucket: &str,
formatter: &Formatter,
) -> Result<Vec<String>, (ExitCode, Vec<String>)> {
let mut keys_to_delete = Vec::new();
let mut continuation_token: Option<String> = None;

loop {
let options = ListOptions {
recursive: true,
max_keys: Some(1000),
continuation_token: continuation_token.clone(),
..Default::default()
};

match client.list_objects(path, options).await {
Ok(result) => {
for item in result.items {
if !item.is_dir {
keys_to_delete.push(item.key);
}
}

if result.truncated {
continuation_token = result.continuation_token;
} else {
break;
}
}
Err(e) => return Err(list_error(e, bucket, formatter)),
}
}

Ok(keys_to_delete)
}

async fn list_version_keys(
client: &S3Client,
path: &RemotePath,
bucket: &str,
formatter: &Formatter,
) -> Result<Vec<String>, (ExitCode, Vec<String>)> {
let mut keys_to_delete = HashSet::new();
let mut key_marker: Option<String> = None;
let mut version_id_marker: Option<String> = None;

loop {
match client
.list_object_versions_page_with_markers(
path,
Some(1000),
key_marker.as_deref(),
version_id_marker.as_deref(),
)
.await
{
Ok(result) => {
for item in result.items {
keys_to_delete.insert(item.key);
}

if result.truncated {
key_marker = result.continuation_token;
version_id_marker = result.version_id_marker;
} else {
break;
}
}
Err(e) => return Err(list_error(e, bucket, formatter)),
}
}

Ok(keys_to_delete.into_iter().collect())
}

fn list_error(
error: rc_core::Error,
bucket: &str,
formatter: &Formatter,
) -> (ExitCode, Vec<String>) {
let err_str = error.to_string();
if err_str.contains("NotFound") || err_str.contains("NoSuchBucket") {
let code = formatter.fail_with_suggestion(
ExitCode::NotFound,
&format!("Bucket not found: {bucket}"),
"Check the bucket path and retry the remove command.",
);
return (code, vec![]);
}

let code = formatter.fail(
ExitCode::NetworkError,
&format!("Failed to list objects: {error}"),
);
(code, vec![])
}

fn validate_removal_scope(key: &str, recursive: bool) -> Result<(), String> {
if !recursive && (key.is_empty() || key.ends_with('/')) {
return Err("Bucket and prefix removal requires the explicit --recursive flag".to_string());
Expand Down Expand Up @@ -526,6 +587,40 @@ mod tests {
assert!(options.force_delete);
}

#[test]
fn test_delete_request_options_enable_force_delete_for_versions() {
let args = RmArgs {
paths: vec!["test/bucket/object.txt".to_string()],
recursive: false,
force: false,
dry_run: false,
incomplete: false,
versions: true,
bypass: false,
purge: false,
};

let options = delete_request_options(&args);
assert!(options.force_delete);
}

#[test]
fn test_delete_request_options_enable_governance_bypass() {
let args = RmArgs {
paths: vec!["test/bucket/object.txt".to_string()],
recursive: false,
force: false,
dry_run: false,
incomplete: false,
versions: false,
bypass: true,
purge: false,
};

let options = delete_request_options(&args);
assert!(options.bypass_governance_retention);
}

#[test]
fn test_delete_request_options_keep_force_delete_disabled_by_default() {
let args = RmArgs {
Expand Down
4 changes: 1 addition & 3 deletions crates/cli/tests/help_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@ fn top_level_command_help_contract() {
"--recursive",
"--force",
"--dry-run",
"--incomplete",
"--versions",
"--bypass",
"--purge",
Expand Down Expand Up @@ -268,7 +267,7 @@ fn top_level_command_help_contract() {
HelpCase {
args: &["rb"],
usage: "Usage: rc rb [OPTIONS] <TARGET>",
expected_tokens: &["--force", "--dangerous"],
expected_tokens: &["--force"],
},
HelpCase {
args: &["cat"],
Expand Down Expand Up @@ -321,7 +320,6 @@ fn top_level_command_help_contract() {
"--force",
"--purge",
"--dry-run",
"--incomplete",
"--versions",
"--bypass",
"Examples:",
Expand Down
41 changes: 39 additions & 2 deletions crates/cli/tests/rm_purge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a s
fn response_for(request: &CapturedRequest) -> String {
match request.method.as_str() {
"GET" if request.path.contains("list-type=2") => xml_response(200, list_objects_body()),
"GET" if request.path.contains("versions") => xml_response(200, list_versions_body()),
"DELETE" => xml_response(204, ""),
"POST" if request.path.contains("delete") => xml_response(200, delete_objects_body()),
_ => xml_response(500, "<Error><Code>UnexpectedRequest</Code></Error>"),
Expand Down Expand Up @@ -277,6 +278,42 @@ fn list_objects_body() -> &'static str {
</ListBucketResult>"#
}

fn list_versions_body() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8"?>
<ListVersionsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>bucket</Name>
<Prefix>purge-prefix/</Prefix>
<KeyMarker></KeyMarker>
<VersionIdMarker></VersionIdMarker>
<MaxKeys>1000</MaxKeys>
<IsTruncated>false</IsTruncated>
<Version>
<Key>purge-prefix/a.txt</Key>
<VersionId>v1</VersionId>
<IsLatest>false</IsLatest>
<LastModified>2026-05-01T00:00:00.000Z</LastModified>
<ETag>&quot;etag-a&quot;</ETag>
<Size>1</Size>
<StorageClass>STANDARD</StorageClass>
</Version>
<DeleteMarker>
<Key>purge-prefix/a.txt</Key>
<VersionId>v2</VersionId>
<IsLatest>true</IsLatest>
<LastModified>2026-05-01T00:00:01.000Z</LastModified>
</DeleteMarker>
<Version>
<Key>purge-prefix/nested/b.txt</Key>
<VersionId>v3</VersionId>
<IsLatest>true</IsLatest>
<LastModified>2026-05-01T00:00:00.000Z</LastModified>
<ETag>&quot;etag-b&quot;</ETag>
<Size>1</Size>
<StorageClass>STANDARD</StorageClass>
</Version>
</ListVersionsResult>"#
}

fn delete_objects_body() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8"?>
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
Expand Down Expand Up @@ -319,12 +356,12 @@ fn rm_recursive_purge_deletes_each_key_with_force_header() {
let requests = server.captured_requests();
let list_requests: Vec<_> = requests
.iter()
.filter(|request| request.method == "GET" && request.path.contains("list-type=2"))
.filter(|request| request.method == "GET" && request.path.contains("versions"))
.collect();
assert_eq!(list_requests.len(), 1, "requests: {requests:#?}");
assert!(
list_requests[0].path.contains("prefix=purge-prefix%2F"),
"list request should include the recursive prefix: {requests:#?}"
"version list request should include the recursive prefix: {requests:#?}"
);

let delete_requests: Vec<_> = requests
Expand Down
Loading