From 63253ef3cfbad63115130fbf4df04200f9f70dc1 Mon Sep 17 00:00:00 2001 From: 0x5b62656e5d Date: Sat, 11 Apr 2026 14:33:20 +0800 Subject: [PATCH 1/6] Implement commands for multipart Implements commands for listing and deleting multipart uploads --- src/cli.rs | 22 +++++++++++++++++ src/main.rs | 31 ++++++++++++++++++------ src/multipart/delete.rs | 53 +++++++++++++++++++++++++++++++++++++++++ src/multipart/list.rs | 51 +++++++++++++++++++++++++++++++++++++++ src/multipart/mod.rs | 2 ++ 5 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 src/multipart/delete.rs create mode 100644 src/multipart/list.rs create mode 100644 src/multipart/mod.rs diff --git a/src/cli.rs b/src/cli.rs index 6939326..39f1794 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -17,6 +17,10 @@ pub enum Commands { #[command(subcommand)] commands: FileCommands, }, + Multipart { + #[command(subcommand)] + commands: MultpartCommands, + }, Init {}, } @@ -76,3 +80,21 @@ pub enum FileCommands { override_filename: Option, }, } + +#[derive(Subcommand, Debug)] +pub enum MultpartCommands { + Delete { + #[arg(required = true)] + bucket: String, + + #[arg(required = true)] + key: String, + + #[arg(required = true)] + timestamp_id: String, + }, + List { + #[arg(required = true)] + bucket: String, + }, +} diff --git a/src/main.rs b/src/main.rs index 22e8085..b0d76a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,11 @@ use crate::{ - buckets::{create::create_bucket, delete::delete_bucket, list_buckets::list_buckets}, - cli::{BucketCommands, Cli, Commands, FileCommands}, - client::{ + buckets::{create::create_bucket, delete::delete_bucket, list_buckets::list_buckets}, cli::{BucketCommands, Cli, Commands, FileCommands, MultpartCommands}, client::{ config::{Config, Regions, get_config, get_regions, init_config}, init::init_regions, s3_client::build_client, - }, - files::{ + }, files::{ delete::delete_file, download::download_file, list_files::list_files, upload::upload_file, - }, - util::get_bucket_region, + }, multipart::delete::delete_multipart_upload, util::get_bucket_region }; use anyhow::{Result, bail}; use aws_sdk_s3::Client; @@ -20,6 +16,7 @@ mod buckets; mod cli; mod client; mod files; +mod multipart; mod util; #[::tokio::main] @@ -161,6 +158,26 @@ async fn main() -> Result<()> { ); } }, + Commands::Multipart { commands } => match commands { + MultpartCommands::Delete { bucket, key, timestamp_id } => { + let client: Client = build_client( + &config.default, + get_bucket_region(&mut regions, bucket.clone(), &default_client).await?, + ) + .await?; + + delete_multipart_upload(&client, bucket, key, timestamp_id).await?; + }, + MultpartCommands::List { bucket } => { + let client: Client = build_client( + &config.default, + get_bucket_region(&mut regions, bucket.clone(), &default_client).await?, + ) + .await?; + + println!("{}", multipart::list::list_multipart_uploads(&client, &bucket).await?); + } + }, Commands::Init {} => { init_regions().await?; } diff --git a/src/multipart/delete.rs b/src/multipart/delete.rs new file mode 100644 index 0000000..c11e40f --- /dev/null +++ b/src/multipart/delete.rs @@ -0,0 +1,53 @@ +use aws_sdk_s3::{Client, operation::list_multipart_uploads::ListMultipartUploadsOutput}; +use chrono::{DateTime, Local}; + +/// Deletes an incomplete multipart upload from an S3 bucket +/// # Arguments +/// * `client` - A reference to the S3 client +/// * `bucket` - The name of the bucket +/// * `key` - The key (path) of the multipart upload to delete +/// * `timestamp_id` - The timestamp ID of the multipart upload to delete +/// # Returns +/// * `Result<(), anyhow::Error>` - `Ok(())` if successful, error if the operation fails +pub async fn delete_multipart_upload( + client: &Client, + bucket: String, + key: String, + timestamp_id: String, +) -> Result<(), anyhow::Error> { + let res: ListMultipartUploadsOutput = client + .list_multipart_uploads() + .bucket(&bucket) + .send() + .await?; + + if res.uploads.is_none() { + return Ok(()); + } + + let upload_id = res.uploads.unwrap().iter().filter(|u: &&aws_sdk_s3::types::MultipartUpload| { + let timestamp = + DateTime::from_timestamp_millis(u.initiated().unwrap().to_millis().unwrap()) + .unwrap() + .with_timezone(&Local).timestamp_millis(); + + timestamp == timestamp_id.parse::().unwrap() + }).next().map(|u| u.upload_id.clone()); + + if upload_id.is_none() { + return Ok(()); + } + + if upload_id.as_ref().unwrap().is_none() { + return Ok(()); + } + + client.abort_multipart_upload() + .bucket(bucket) + .key(key) + .upload_id(upload_id.unwrap().unwrap()) + .send() + .await?; + + Ok(()) +} diff --git a/src/multipart/list.rs b/src/multipart/list.rs new file mode 100644 index 0000000..c0ee14c --- /dev/null +++ b/src/multipart/list.rs @@ -0,0 +1,51 @@ +use anyhow::bail; +use aws_sdk_s3::{Client, operation::list_multipart_uploads::ListMultipartUploadsOutput}; +use chrono::{DateTime, Local}; +use tabled::{Table, Tabled}; + +use crate::util::build_table; + +#[derive(Tabled)] +struct MultipartUploadInfo { + num: usize, + key: String, + initiated: String, + timestamp_id: String, +} + +/// Lists incomplete multipart uploads in an S3 bucket +/// # Arguments +/// * `client` - A reference to the S3 client +/// * `bucket` - The name of the bucket +/// # Returns +/// * `Result` - `Table` if successful, error if the operation fails +pub async fn list_multipart_uploads(client: &Client, bucket: &str) -> Result { + let res: ListMultipartUploadsOutput = client + .list_multipart_uploads() + .bucket(bucket) + .send() + .await?; + + if res.uploads.is_none() { + bail!("No multipart uploads found in the bucket '{}'", bucket) + } + + let table: Table = build_table( + res.uploads.unwrap(), + |i: usize, o: &aws_sdk_s3::types::MultipartUpload| { + let timestamp = + DateTime::from_timestamp_millis(o.initiated().unwrap().to_millis().unwrap()) + .unwrap() + .with_timezone(&Local); + + MultipartUploadInfo { + num: i + 1, + key: o.key().unwrap().to_string(), + initiated: timestamp.format("%b %d, %Y - %H:%M:%S").to_string(), + timestamp_id: timestamp.timestamp_millis().to_string(), + } + }, + ); + + Ok(table) +} diff --git a/src/multipart/mod.rs b/src/multipart/mod.rs new file mode 100644 index 0000000..3bf48b7 --- /dev/null +++ b/src/multipart/mod.rs @@ -0,0 +1,2 @@ +pub mod delete; +pub mod list; From e28ab02a7fb1c6984c88915095cc4f36c2daa773 Mon Sep 17 00:00:00 2001 From: 0x5b62656e5d Date: Sat, 11 Apr 2026 14:34:41 +0800 Subject: [PATCH 2/6] Update readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index c260299..5ac53de 100644 --- a/README.md +++ b/README.md @@ -63,4 +63,9 @@ COMMANDS Downloads a file from a bucket to a given location (optionally rename it) files upload [-o ] Uploads a file into a bucket (optionally rename it) + + multipart list + Lists all multipart uploads in a bucket + multipart delete + Deletes a multipart upload in a bucket ``` \ No newline at end of file From 602d02a2df19c162693944211853b96797bcd7aa Mon Sep 17 00:00:00 2001 From: 0x5b62656e5d Date: Sat, 11 Apr 2026 14:39:07 +0800 Subject: [PATCH 3/6] Use find_map instead of filter --- src/multipart/delete.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/multipart/delete.rs b/src/multipart/delete.rs index c11e40f..b4e12b6 100644 --- a/src/multipart/delete.rs +++ b/src/multipart/delete.rs @@ -25,14 +25,19 @@ pub async fn delete_multipart_upload( return Ok(()); } - let upload_id = res.uploads.unwrap().iter().filter(|u: &&aws_sdk_s3::types::MultipartUpload| { + let upload_id = res.uploads.unwrap().iter().find_map(|u: &aws_sdk_s3::types::MultipartUpload| { let timestamp = - DateTime::from_timestamp_millis(u.initiated().unwrap().to_millis().unwrap()) - .unwrap() - .with_timezone(&Local).timestamp_millis(); + DateTime::from_timestamp_millis(u.initiated().unwrap().to_millis().unwrap()) + .unwrap() + .with_timezone(&Local) + .timestamp_millis(); - timestamp == timestamp_id.parse::().unwrap() - }).next().map(|u| u.upload_id.clone()); + if timestamp == timestamp_id.parse::().unwrap() && u.key().unwrap() == key { + Some(u.upload_id.clone()) + } else { + None + } + }); if upload_id.is_none() { return Ok(()); @@ -41,13 +46,14 @@ pub async fn delete_multipart_upload( if upload_id.as_ref().unwrap().is_none() { return Ok(()); } - - client.abort_multipart_upload() + + client + .abort_multipart_upload() .bucket(bucket) .key(key) .upload_id(upload_id.unwrap().unwrap()) .send() .await?; - + Ok(()) } From 1e72fd19e8f9bd8529ddcaa118ad74716678ffba Mon Sep 17 00:00:00 2001 From: 0x5b62656e5d Date: Sat, 11 Apr 2026 14:39:27 +0800 Subject: [PATCH 4/6] Bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f63cb55..110ea0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "pepper-s3-cli" -version = "1.3.0" +version = "1.4.0" dependencies = [ "anyhow", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 7f0225b..3abf5b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pepper-s3-cli" -version = "1.3.0" +version = "1.4.0" edition = "2024" description = "A simple CLI tool to manage S3 buckets and files from the terminal" license = "MIT" From 901ae97c4af79e2a55ba734fb57e65c300e21449 Mon Sep 17 00:00:00 2001 From: 0x5b62656e5d Date: Sat, 11 Apr 2026 14:40:10 +0800 Subject: [PATCH 5/6] Update example.config.toml --- example.config.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example.config.toml b/example.config.toml index b6038d6..18a8727 100644 --- a/example.config.toml +++ b/example.config.toml @@ -1,5 +1,4 @@ [default] key_id = "Key ID" secret_key = "Secret Key" -endpoint_url = "Endpoint URL" -is_aws = false \ No newline at end of file +endpoint_url = "Endpoint URL" \ No newline at end of file From ad8baa569541c5105626e63cd010746db210cffb Mon Sep 17 00:00:00 2001 From: 0x5b62656e5d Date: Sat, 11 Apr 2026 14:44:00 +0800 Subject: [PATCH 6/6] Format code --- src/main.rs | 24 ++++++++++++++++++------ src/multipart/delete.rs | 30 +++++++++++++++++------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/main.rs b/src/main.rs index b0d76a8..304d99f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,16 @@ use crate::{ - buckets::{create::create_bucket, delete::delete_bucket, list_buckets::list_buckets}, cli::{BucketCommands, Cli, Commands, FileCommands, MultpartCommands}, client::{ + buckets::{create::create_bucket, delete::delete_bucket, list_buckets::list_buckets}, + cli::{BucketCommands, Cli, Commands, FileCommands, MultpartCommands}, + client::{ config::{Config, Regions, get_config, get_regions, init_config}, init::init_regions, s3_client::build_client, - }, files::{ + }, + files::{ delete::delete_file, download::download_file, list_files::list_files, upload::upload_file, - }, multipart::delete::delete_multipart_upload, util::get_bucket_region + }, + multipart::delete::delete_multipart_upload, + util::get_bucket_region, }; use anyhow::{Result, bail}; use aws_sdk_s3::Client; @@ -159,7 +164,11 @@ async fn main() -> Result<()> { } }, Commands::Multipart { commands } => match commands { - MultpartCommands::Delete { bucket, key, timestamp_id } => { + MultpartCommands::Delete { + bucket, + key, + timestamp_id, + } => { let client: Client = build_client( &config.default, get_bucket_region(&mut regions, bucket.clone(), &default_client).await?, @@ -167,7 +176,7 @@ async fn main() -> Result<()> { .await?; delete_multipart_upload(&client, bucket, key, timestamp_id).await?; - }, + } MultpartCommands::List { bucket } => { let client: Client = build_client( &config.default, @@ -175,7 +184,10 @@ async fn main() -> Result<()> { ) .await?; - println!("{}", multipart::list::list_multipart_uploads(&client, &bucket).await?); + println!( + "{}", + multipart::list::list_multipart_uploads(&client, &bucket).await? + ); } }, Commands::Init {} => { diff --git a/src/multipart/delete.rs b/src/multipart/delete.rs index b4e12b6..f386147 100644 --- a/src/multipart/delete.rs +++ b/src/multipart/delete.rs @@ -25,19 +25,23 @@ pub async fn delete_multipart_upload( return Ok(()); } - let upload_id = res.uploads.unwrap().iter().find_map(|u: &aws_sdk_s3::types::MultipartUpload| { - let timestamp = - DateTime::from_timestamp_millis(u.initiated().unwrap().to_millis().unwrap()) - .unwrap() - .with_timezone(&Local) - .timestamp_millis(); - - if timestamp == timestamp_id.parse::().unwrap() && u.key().unwrap() == key { - Some(u.upload_id.clone()) - } else { - None - } - }); + let upload_id = + res.uploads + .unwrap() + .iter() + .find_map(|u: &aws_sdk_s3::types::MultipartUpload| { + let timestamp = + DateTime::from_timestamp_millis(u.initiated().unwrap().to_millis().unwrap()) + .unwrap() + .with_timezone(&Local) + .timestamp_millis(); + + if timestamp == timestamp_id.parse::().unwrap() && u.key().unwrap() == key { + Some(u.upload_id.clone()) + } else { + None + } + }); if upload_id.is_none() { return Ok(());