Feat: initial features implementation
This commit is contained in:
commit
d3cae9fd20
5 changed files with 2613 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
1967
Cargo.lock
generated
Normal file
1967
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "swfss3"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argh = "0.1.13"
|
||||
s3 = { version = "0.1.22", features = ["blocking", "rustls"] }
|
||||
208
README.md
Normal file
208
README.md
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# swfss3
|
||||
|
||||
`swfss3` is a small CLI for performing **filesystem-like operations** (list/read/write/delete)
|
||||
against a **SeaweedFS S3-compatible** API endpoint using the Rust `s3` crate.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Binary will be at `target/release/swfss3`.
|
||||
|
||||
## Authentication and configuration
|
||||
|
||||
You must provide:
|
||||
|
||||
- **Endpoint**: `--endpoint` (custom domain / S3 API URL)
|
||||
- **Bucket**: `--bucket`
|
||||
- **Region**: `--region` (defaults to `us-east-1`)
|
||||
|
||||
Authentication (choose one):
|
||||
|
||||
- **Flags**: `--access-key-id` and `--secret-access-key` (optional `--session-token`)
|
||||
- **Environment variables**: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`
|
||||
- **Anonymous**: `--anonymous` (unsigned requests)
|
||||
|
||||
If you see `invalid config: missing AWS_ACCESS_KEY_ID`, it means you did not pass credentials
|
||||
flags and `swfss3` could not find the AWS environment variables. Fix it by using **one** of:
|
||||
|
||||
- **Provide keys via flags**:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
--access-key-id YOUR_KEY --secret-access-key YOUR_SECRET \
|
||||
list --prefix some/path/
|
||||
```
|
||||
|
||||
- **Export keys via environment variables**:
|
||||
|
||||
```bash
|
||||
export AWS_ACCESS_KEY_ID=YOUR_KEY
|
||||
export AWS_SECRET_ACCESS_KEY=YOUR_SECRET
|
||||
# optional (temporary credentials)
|
||||
export AWS_SESSION_TOKEN=YOUR_TOKEN
|
||||
```
|
||||
|
||||
- **Use unsigned requests** (only works if the bucket/object is publicly readable/writable):
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style --anonymous \
|
||||
list --prefix some/path/
|
||||
```
|
||||
|
||||
Addressing style (optional, but often important for S3-compatible servers):
|
||||
|
||||
- `--path-style` (recommended for many self-hosted endpoints)
|
||||
- `--virtual-hosted` (bucket as a subdomain)
|
||||
- default is automatic selection
|
||||
|
||||
## Commands
|
||||
|
||||
All commands share the common flags above and then take their own arguments.
|
||||
|
||||
### List objects
|
||||
|
||||
List a prefix (like listing a directory):
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
list --prefix some/path/
|
||||
```
|
||||
|
||||
Recursive listing (no delimiter grouping):
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
list --prefix some/path/ --recursive
|
||||
```
|
||||
|
||||
Fetch all pages:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
list --prefix some/path/ --all
|
||||
```
|
||||
|
||||
### Read an object
|
||||
|
||||
To stdout:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
read some/path/file.txt
|
||||
```
|
||||
|
||||
To a local file:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
read some/path/file.txt --out ./file.txt
|
||||
```
|
||||
|
||||
### Write an object
|
||||
|
||||
Upload a local file to an object key:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
write ./local.bin some/path/local.bin
|
||||
```
|
||||
|
||||
Optionally set `Content-Type`:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
write ./index.html some/path/index.html --content-type text/html
|
||||
```
|
||||
|
||||
### Delete an object
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
delete some/path/local.bin
|
||||
```
|
||||
|
||||
### Stat (HEAD) an object
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
stat some/path/file.txt
|
||||
```
|
||||
|
||||
### Copy / move (rename) an object
|
||||
|
||||
Server-side copy:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
cp some/path/file.txt some/path/file-copy.txt
|
||||
```
|
||||
|
||||
Move (copy then delete):
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
mv some/path/file.txt some/path/renamed.txt
|
||||
```
|
||||
|
||||
### Presigned URLs
|
||||
|
||||
Create a temporary download URL (default 900s expiry):
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
presign-get some/path/file.txt --expires 300
|
||||
```
|
||||
|
||||
Create a temporary upload URL:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
presign-put some/path/upload.bin --expires 300
|
||||
```
|
||||
|
||||
### Delete everything under a prefix
|
||||
|
||||
Dry-run (prints keys that would be deleted):
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
rm-prefix some/path/
|
||||
```
|
||||
|
||||
Actually delete:
|
||||
|
||||
```bash
|
||||
swfss3 --endpoint http://localhost:8333 --bucket mybucket --path-style \
|
||||
rm-prefix some/path/ --yes
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `NoSuchBucket` but the bucket “exists”
|
||||
|
||||
If you get an error like `code=NoSuchBucket`, double-check:
|
||||
|
||||
- **You are hitting the S3 gateway endpoint**, not the filer UI or another reverse-proxied service.
|
||||
- **The bucket exists in the same endpoint/cluster**. Buckets are endpoint-scoped.
|
||||
- **Addressing style**: if one style doesn’t work, try the other:
|
||||
- `--path-style` (common for self-hosted)
|
||||
- `--virtual-hosted` (requires bucket DNS/wildcard)
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
swfss3 --help
|
||||
swfss3 list --help
|
||||
swfss3 read --help
|
||||
swfss3 write --help
|
||||
swfss3 delete --help
|
||||
swfss3 stat --help
|
||||
swfss3 cp --help
|
||||
swfss3 mv --help
|
||||
swfss3 presign-get --help
|
||||
swfss3 presign-put --help
|
||||
swfss3 rm-prefix --help
|
||||
```
|
||||
429
src/main.rs
Normal file
429
src/main.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
use std::{
|
||||
fs::File,
|
||||
io::{self, BufWriter, Write},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use argh::FromArgs;
|
||||
use s3::{AddressingStyle, Auth, BlockingClient, Credentials};
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Manage files in a SeaweedFS S3-compatible bucket.
|
||||
struct Cli {
|
||||
/// S3 endpoint URL, e.g. https://s3.example.com or http://localhost:8333
|
||||
#[argh(option)]
|
||||
endpoint: String,
|
||||
|
||||
/// signing region (SeaweedFS usually accepts "us-east-1")
|
||||
#[argh(option, default = "String::from(\"us-east-1\")")]
|
||||
region: String,
|
||||
|
||||
/// bucket name
|
||||
#[argh(option)]
|
||||
bucket: String,
|
||||
|
||||
/// AWS access key id (falls back to AWS_ACCESS_KEY_ID)
|
||||
#[argh(option)]
|
||||
access_key_id: Option<String>,
|
||||
|
||||
/// AWS secret access key (falls back to AWS_SECRET_ACCESS_KEY)
|
||||
#[argh(option)]
|
||||
secret_access_key: Option<String>,
|
||||
|
||||
/// AWS session token (falls back to AWS_SESSION_TOKEN)
|
||||
#[argh(option)]
|
||||
session_token: Option<String>,
|
||||
|
||||
/// do not sign requests
|
||||
#[argh(switch)]
|
||||
anonymous: bool,
|
||||
|
||||
/// always use path-style URLs (recommended for many S3-compatible servers)
|
||||
#[argh(switch)]
|
||||
path_style: bool,
|
||||
|
||||
/// always use virtual-hosted-style URLs (bucket as subdomain)
|
||||
#[argh(switch)]
|
||||
virtual_hosted: bool,
|
||||
|
||||
#[argh(subcommand)]
|
||||
cmd: Command,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
#[argh(subcommand)]
|
||||
enum Command {
|
||||
List(CmdList),
|
||||
Read(CmdRead),
|
||||
Write(CmdWrite),
|
||||
Delete(CmdDelete),
|
||||
Stat(CmdStat),
|
||||
Cp(CmdCp),
|
||||
Mv(CmdMv),
|
||||
PresignGet(CmdPresignGet),
|
||||
PresignPut(CmdPresignPut),
|
||||
RmPrefix(CmdRmPrefix),
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// List objects under a prefix.
|
||||
#[argh(subcommand, name = "list")]
|
||||
struct CmdList {
|
||||
/// key prefix to list (like a directory path)
|
||||
#[argh(option, default = "String::new()")]
|
||||
prefix: String,
|
||||
|
||||
/// list recursively (do not group by "/")
|
||||
#[argh(switch)]
|
||||
recursive: bool,
|
||||
|
||||
/// fetch and print all pages
|
||||
#[argh(switch)]
|
||||
all: bool,
|
||||
|
||||
/// maximum number of keys per page
|
||||
#[argh(option)]
|
||||
max_keys: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Read an object to a file (or stdout).
|
||||
#[argh(subcommand, name = "read")]
|
||||
struct CmdRead {
|
||||
/// object key
|
||||
#[argh(positional)]
|
||||
key: String,
|
||||
|
||||
/// output file path (omit to write to stdout)
|
||||
#[argh(option)]
|
||||
out: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Write a local file to an object key.
|
||||
#[argh(subcommand, name = "write")]
|
||||
struct CmdWrite {
|
||||
/// local file path to upload
|
||||
#[argh(positional)]
|
||||
file: PathBuf,
|
||||
|
||||
/// destination object key
|
||||
#[argh(positional)]
|
||||
key: String,
|
||||
|
||||
/// optional content-type, e.g. text/plain
|
||||
#[argh(option)]
|
||||
content_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Delete an object.
|
||||
#[argh(subcommand, name = "delete")]
|
||||
struct CmdDelete {
|
||||
/// object key
|
||||
#[argh(positional)]
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Show object metadata (HEAD).
|
||||
#[argh(subcommand, name = "stat")]
|
||||
struct CmdStat {
|
||||
/// object key
|
||||
#[argh(positional)]
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Copy an object to a new key (server-side copy).
|
||||
#[argh(subcommand, name = "cp")]
|
||||
struct CmdCp {
|
||||
/// source key
|
||||
#[argh(positional)]
|
||||
src_key: String,
|
||||
/// destination key
|
||||
#[argh(positional)]
|
||||
dst_key: String,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Move/rename an object key (copy then delete).
|
||||
#[argh(subcommand, name = "mv")]
|
||||
struct CmdMv {
|
||||
/// source key
|
||||
#[argh(positional)]
|
||||
src_key: String,
|
||||
/// destination key
|
||||
#[argh(positional)]
|
||||
dst_key: String,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Create a presigned download URL.
|
||||
#[argh(subcommand, name = "presign-get")]
|
||||
struct CmdPresignGet {
|
||||
/// object key
|
||||
#[argh(positional)]
|
||||
key: String,
|
||||
|
||||
/// expiry in seconds
|
||||
#[argh(option, default = "900")]
|
||||
expires: u64,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Create a presigned upload URL.
|
||||
#[argh(subcommand, name = "presign-put")]
|
||||
struct CmdPresignPut {
|
||||
/// object key
|
||||
#[argh(positional)]
|
||||
key: String,
|
||||
|
||||
/// expiry in seconds
|
||||
#[argh(option, default = "900")]
|
||||
expires: u64,
|
||||
}
|
||||
|
||||
#[derive(FromArgs, Debug)]
|
||||
/// Delete all objects under a prefix (paged + batched deletes).
|
||||
#[argh(subcommand, name = "rm-prefix")]
|
||||
struct CmdRmPrefix {
|
||||
/// key prefix to delete
|
||||
#[argh(positional)]
|
||||
prefix: String,
|
||||
|
||||
/// perform deletion (required); without this, prints keys that would be deleted
|
||||
#[argh(switch)]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli: Cli = argh::from_env();
|
||||
if let Err(err) = run(cli) {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<(), s3::Error> {
|
||||
if cli.path_style && cli.virtual_hosted {
|
||||
return Err(s3::Error::invalid_config(
|
||||
"choose only one of --path-style or --virtual-hosted",
|
||||
));
|
||||
}
|
||||
|
||||
let addressing_style = if cli.path_style {
|
||||
AddressingStyle::Path
|
||||
} else if cli.virtual_hosted {
|
||||
AddressingStyle::VirtualHosted
|
||||
} else {
|
||||
AddressingStyle::Auto
|
||||
};
|
||||
|
||||
let auth = if cli.anonymous {
|
||||
Auth::Anonymous
|
||||
} else if let (Some(akid), Some(secret)) = (cli.access_key_id, cli.secret_access_key) {
|
||||
let mut creds = Credentials::new(akid, secret)?;
|
||||
if let Some(token) = cli.session_token {
|
||||
creds = creds.with_session_token(token)?;
|
||||
}
|
||||
Auth::Static(creds)
|
||||
} else {
|
||||
Auth::from_env()?
|
||||
};
|
||||
|
||||
let client = BlockingClient::builder(&cli.endpoint)?
|
||||
.region(cli.region)
|
||||
.auth(auth)
|
||||
.addressing_style(addressing_style)
|
||||
.build()?;
|
||||
|
||||
match cli.cmd {
|
||||
Command::List(cmd) => cmd_list(&client, &cli.bucket, cmd),
|
||||
Command::Read(cmd) => cmd_read(&client, &cli.bucket, cmd),
|
||||
Command::Write(cmd) => cmd_write(&client, &cli.bucket, cmd),
|
||||
Command::Delete(cmd) => cmd_delete(&client, &cli.bucket, cmd),
|
||||
Command::Stat(cmd) => cmd_stat(&client, &cli.bucket, cmd),
|
||||
Command::Cp(cmd) => cmd_cp(&client, &cli.bucket, cmd),
|
||||
Command::Mv(cmd) => cmd_mv(&client, &cli.bucket, cmd),
|
||||
Command::PresignGet(cmd) => cmd_presign_get(&client, &cli.bucket, cmd),
|
||||
Command::PresignPut(cmd) => cmd_presign_put(&client, &cli.bucket, cmd),
|
||||
Command::RmPrefix(cmd) => cmd_rm_prefix(&client, &cli.bucket, cmd),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list(client: &BlockingClient, bucket: &str, cmd: CmdList) -> Result<(), s3::Error> {
|
||||
let mut req = client.objects().list_v2(bucket);
|
||||
if !cmd.prefix.is_empty() {
|
||||
req = req.prefix(cmd.prefix);
|
||||
}
|
||||
if !cmd.recursive {
|
||||
req = req.delimiter("/");
|
||||
}
|
||||
if let Some(max_keys) = cmd.max_keys {
|
||||
req = req.max_keys(max_keys);
|
||||
}
|
||||
|
||||
let print_page = |page: s3::types::ListObjectsV2Output| {
|
||||
for p in page.common_prefixes {
|
||||
println!("{p}");
|
||||
}
|
||||
for o in page.contents {
|
||||
println!("{}", o.key);
|
||||
}
|
||||
};
|
||||
|
||||
if cmd.all {
|
||||
for page in req.pager() {
|
||||
print_page(page?);
|
||||
}
|
||||
} else {
|
||||
print_page(req.send()?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_read(client: &BlockingClient, bucket: &str, cmd: CmdRead) -> Result<(), s3::Error> {
|
||||
let obj = client.objects().get(bucket, cmd.key).send()?;
|
||||
|
||||
match cmd.out {
|
||||
Some(path) => {
|
||||
let file = File::create(path)
|
||||
.map_err(|e| s3::Error::transport("failed to create output file", Some(Box::new(e))))?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
obj.write_to(&mut writer)?;
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|e| s3::Error::transport("failed to flush output file", Some(Box::new(e))))?;
|
||||
}
|
||||
None => {
|
||||
let stdout = io::stdout();
|
||||
let mut handle = stdout.lock();
|
||||
obj.write_to(&mut handle)?;
|
||||
handle
|
||||
.flush()
|
||||
.map_err(|e| s3::Error::transport("failed to flush stdout", Some(Box::new(e))))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_write(client: &BlockingClient, bucket: &str, cmd: CmdWrite) -> Result<(), s3::Error> {
|
||||
let file = File::open(&cmd.file)
|
||||
.map_err(|e| s3::Error::transport("failed to open input file", Some(Box::new(e))))?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map(|m| m.len())
|
||||
.map_err(|e| s3::Error::transport("failed to stat input file", Some(Box::new(e))))?;
|
||||
|
||||
let mut req = client
|
||||
.objects()
|
||||
.put(bucket, cmd.key)
|
||||
.body_reader_sized(file, len);
|
||||
|
||||
if let Some(ct) = cmd.content_type {
|
||||
req = req.content_type(ct);
|
||||
}
|
||||
|
||||
req.send()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_delete(client: &BlockingClient, bucket: &str, cmd: CmdDelete) -> Result<(), s3::Error> {
|
||||
client.objects().delete(bucket, cmd.key).send()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_stat(client: &BlockingClient, bucket: &str, cmd: CmdStat) -> Result<(), s3::Error> {
|
||||
let out = client.objects().head(bucket, cmd.key).send()?;
|
||||
if let Some(len) = out.content_length {
|
||||
println!("content_length={len}");
|
||||
} else {
|
||||
println!("content_length=<unknown>");
|
||||
}
|
||||
if let Some(ct) = out.content_type {
|
||||
println!("content_type={ct}");
|
||||
}
|
||||
if let Some(etag) = out.etag {
|
||||
println!("etag={etag}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_cp(client: &BlockingClient, bucket: &str, cmd: CmdCp) -> Result<(), s3::Error> {
|
||||
client
|
||||
.objects()
|
||||
.copy(bucket, cmd.src_key, bucket, cmd.dst_key)
|
||||
.send()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_mv(client: &BlockingClient, bucket: &str, cmd: CmdMv) -> Result<(), s3::Error> {
|
||||
client
|
||||
.objects()
|
||||
.copy(bucket, cmd.src_key.clone(), bucket, cmd.dst_key)
|
||||
.send()?;
|
||||
client.objects().delete(bucket, cmd.src_key).send()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_presign_get(
|
||||
client: &BlockingClient,
|
||||
bucket: &str,
|
||||
cmd: CmdPresignGet,
|
||||
) -> Result<(), s3::Error> {
|
||||
let presigned = client
|
||||
.objects()
|
||||
.presign_get(bucket, cmd.key)
|
||||
.expires_in(Duration::from_secs(cmd.expires))
|
||||
.build()?;
|
||||
println!("{}", presigned.url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_presign_put(
|
||||
client: &BlockingClient,
|
||||
bucket: &str,
|
||||
cmd: CmdPresignPut,
|
||||
) -> Result<(), s3::Error> {
|
||||
let presigned = client
|
||||
.objects()
|
||||
.presign_put(bucket, cmd.key)
|
||||
.expires_in(Duration::from_secs(cmd.expires))
|
||||
.build()?;
|
||||
println!("{}", presigned.url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_rm_prefix(client: &BlockingClient, bucket: &str, cmd: CmdRmPrefix) -> Result<(), s3::Error> {
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
|
||||
for page in client.objects().list_v2(bucket).prefix(cmd.prefix).pager() {
|
||||
let page = page?;
|
||||
for obj in page.contents {
|
||||
keys.push(obj.key);
|
||||
}
|
||||
}
|
||||
|
||||
if !cmd.yes {
|
||||
for k in &keys {
|
||||
println!("{k}");
|
||||
}
|
||||
eprintln!("dry run: {} objects (re-run with --yes to delete)", keys.len());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// S3 multi-delete limit is 1000 keys per request.
|
||||
for chunk in keys.chunks(1000) {
|
||||
client
|
||||
.objects()
|
||||
.delete_objects(bucket)
|
||||
.objects(chunk.iter().cloned())
|
||||
.send()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue