├── .github ├── dependabot.yml └── workflows │ └── continuos-integration.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── benches └── actions.rs ├── examples ├── create_bucket.rs ├── list_objects.rs ├── multipart_upload.rs ├── sign_get.rs └── sign_put.rs ├── src ├── actions │ ├── create_bucket.rs │ ├── delete_bucket.rs │ ├── delete_object.rs │ ├── delete_objects.rs │ ├── get_bucket_policy.rs │ ├── get_object.rs │ ├── head_bucket.rs │ ├── head_object.rs │ ├── list_objects_v2.rs │ ├── mod.rs │ ├── multipart_upload │ │ ├── abort.rs │ │ ├── complete.rs │ │ ├── create.rs │ │ ├── list_parts.rs │ │ ├── mod.rs │ │ └── upload.rs │ └── put_object.rs ├── base64.rs ├── bucket.rs ├── credentials │ ├── mod.rs │ ├── rotating.rs │ └── serde.rs ├── lib.rs ├── map.rs ├── method.rs ├── signing │ ├── canonical_request.rs │ ├── mod.rs │ ├── signature.rs │ ├── string_to_sign.rs │ └── util.rs ├── sorting_iter.rs └── time.rs └── tests ├── common.rs ├── create_delete_bucket.rs ├── delete_objects.rs ├── list_parts.rs └── upload_download.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/continuos-integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | fmt: 7 | runs-on: ubuntu-24.04 8 | 9 | steps: 10 | 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Install Rust 15 | run: | 16 | rustup update --no-self-update stable 17 | rustup component add rustfmt 18 | 19 | - name: Run fmt 20 | run: cargo fmt -- --check 21 | 22 | clippy: 23 | runs-on: ubuntu-24.04 24 | 25 | steps: 26 | 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | 30 | - name: Install Rust 31 | run: | 32 | rustup update --no-self-update stable 33 | rustup component add clippy 34 | 35 | - name: Run clippy 36 | run: cargo clippy 37 | 38 | test: 39 | strategy: 40 | matrix: 41 | platform: [ubuntu-24.04, windows-latest, macos-latest] 42 | 43 | runs-on: ${{ matrix.platform }} 44 | 45 | steps: 46 | 47 | - name: Checkout code 48 | uses: actions/checkout@v4 49 | 50 | - name: Install and run minio 51 | if: matrix.platform == 'ubuntu-24.04' 52 | run: | 53 | MINIO_RELEASE="2024-12-18T13-15-44Z" 54 | LINK="https://dl.min.io/server/minio/release/linux-amd64/archive" 55 | curl --output minio "$LINK/minio.RELEASE.$MINIO_RELEASE" 56 | chmod +x minio 57 | ./minio server minio_data/ & 58 | 59 | - name: Run tests 60 | if: matrix.platform == 'ubuntu-24.04' 61 | run: cargo test 62 | 63 | - name: Run tests 64 | if: matrix.platform != 'ubuntu-24.04' 65 | run: cargo test --lib 66 | 67 | - name: Run tests no default features 68 | run: cargo test --lib --no-default-features 69 | 70 | msrv: 71 | runs-on: ubuntu-24.04 72 | 73 | steps: 74 | 75 | - name: Checkout code 76 | uses: actions/checkout@v4 77 | 78 | - name: Install Rust 79 | run: rustup default 1.72 80 | 81 | - name: Run tests 82 | run: cargo test --lib 83 | 84 | coverage: 85 | runs-on: ubuntu-24.04 86 | 87 | steps: 88 | 89 | - name: Checkout code 90 | uses: actions/checkout@v4 91 | 92 | - name: Install cargo-tarpaulin 93 | run: | 94 | LINK="https://github.com/xd009642/tarpaulin/releases/download/0.31.3/cargo-tarpaulin-x86_64-unknown-linux-gnu.tar.gz" 95 | curl -L --output tarpaulin.tar.gz "$LINK" 96 | tar -xzvf tarpaulin.tar.gz 97 | chmod +x cargo-tarpaulin 98 | 99 | - name: Run cargo-tarpaulin 100 | run: ./cargo-tarpaulin tarpaulin --lib --out Xml 101 | 102 | - name: Upload to codecov.io 103 | uses: codecov/codecov-action@v5.3.1 104 | with: 105 | token: ${{ secrets.CODECOV_TOKEN }} 106 | args: '--lib' 107 | 108 | - name: Archive code coverage results 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: code-coverage-report 112 | path: cobertura.xml 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | cobertura.xml 3 | 4 | .idea 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rusty-s3" 3 | version = "0.7.0" 4 | authors = ["Paolo Barbolini ", "Federico Guerinoni "] 5 | description = "Simple pure Rust AWS S3 Client following a Sans-IO approach" 6 | keywords = ["aws", "s3", "minio"] 7 | categories = ["web-programming", "api-bindings"] 8 | repository = "https://github.com/paolobarbolini/rusty-s3" 9 | license = "BSD-2-Clause" 10 | documentation = "https://docs.rs/rusty-s3" 11 | readme = "README.md" 12 | edition = "2021" 13 | rust-version = "1.72" 14 | 15 | [dependencies] 16 | hmac = "0.12.1" 17 | sha2 = "0.10" 18 | jiff = { version = "0.2", default-features = false, features = ["std"] } 19 | url = "2.2.0" 20 | percent-encoding = "2.1.0" 21 | zeroize = "1" 22 | 23 | # optional 24 | base64 = { version = "0.22", optional = true } 25 | quick-xml = { version = "0.37", features = ["serialize"], optional = true } 26 | md-5 = { version = "0.10", optional = true } 27 | serde = { version = "1", features = ["derive"], optional = true } 28 | serde_json = { version = "1", optional = true } 29 | 30 | [features] 31 | default = ["full"] 32 | wasm_bindgen = ["jiff/js"] 33 | full = ["dep:base64", "dep:quick-xml", "dep:md-5", "dep:serde", "dep:serde_json", "jiff/serde"] 34 | 35 | [dev-dependencies] 36 | tokio = { version = "1.0.1", features = ["macros", "fs", "rt-multi-thread"] } 37 | reqwest = "0.12" 38 | getrandom = "0.2" 39 | hex = "0.4" 40 | pretty_assertions = "1" 41 | criterion = "0.5" 42 | 43 | [[bench]] 44 | name = "actions" 45 | harness = false 46 | 47 | [lints.rust] 48 | unreachable_pub = "warn" 49 | 50 | [lints.clippy] 51 | complexity = "warn" 52 | correctness = "warn" 53 | nursery = "warn" 54 | pedantic = "warn" 55 | perf = "warn" 56 | style = "warn" 57 | suspicious = "warn" 58 | unused_trait_names = "warn" 59 | ref_patterns = "warn" 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2020-2024, Paolo Barbolini 4 | Copyright (c) 2020-2024, Federico Guerinoni 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rusty-s3 2 | 3 | [![crates.io](https://img.shields.io/crates/v/rusty-s3.svg)](https://crates.io/crates/rusty-s3) 4 | [![Documentation](https://docs.rs/rusty-s3/badge.svg)](https://docs.rs/rusty-s3) 5 | [![dependency status](https://deps.rs/crate/rusty-s3/0.7.0/status.svg)](https://deps.rs/crate/rusty-s3/0.7.0) 6 | [![Rustc Version 1.72+](https://img.shields.io/badge/rustc-1.72+-lightgray.svg)](https://blog.rust-lang.org/2023/08/24/Rust-1.72.0.html) 7 | [![CI](https://github.com/paolobarbolini/rusty-s3/workflows/CI/badge.svg)](https://github.com/paolobarbolini/rusty-s3/actions?query=workflow%3ACI) 8 | [![codecov](https://codecov.io/gh/paolobarbolini/rusty-s3/branch/main/graph/badge.svg?token=K0YPC21N8D)](https://codecov.io/gh/paolobarbolini/rusty-s3) 9 | 10 | Simple pure Rust AWS S3 Client following a Sans-IO approach, with a modern 11 | and rusty take onto s3's APIs. 12 | 13 | Request signing and response parsing capabilities are provided for the 14 | most common S3 actions, using AWS Signature Version 4. 15 | 16 | Minio compatibility tested on every commit by GitHub Actions. 17 | 18 | ## Examples 19 | 20 | ```rust 21 | use std::env; 22 | use std::time::Duration; 23 | use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle}; 24 | 25 | // setting up a bucket 26 | let endpoint = "https://s3.dualstack.eu-west-1.amazonaws.com".parse().expect("endpoint is a valid Url"); 27 | let path_style = UrlStyle::VirtualHost; 28 | let name = "rusty-s3"; 29 | let region = "eu-west-1"; 30 | let bucket = Bucket::new(endpoint, path_style, name, region).expect("Url has a valid scheme and host"); 31 | 32 | // setting up the credentials 33 | let key = env::var("AWS_ACCESS_KEY_ID").expect("AWS_ACCESS_KEY_ID is set and a valid String"); 34 | let secret = env::var("AWS_SECRET_ACCESS_KEY").expect("AWS_ACCESS_KEY_ID is set and a valid String"); 35 | let credentials = Credentials::new(key, secret); 36 | 37 | // signing a request 38 | let presigned_url_duration = Duration::from_secs(60 * 60); 39 | let action = bucket.get_object(Some(&credentials), "duck.jpg"); 40 | println!("GET {}", action.sign(presigned_url_duration)); 41 | ``` 42 | 43 | More examples can be found in the examples directory on GitHub. 44 | 45 | ## Supported S3 actions 46 | 47 | * Bucket level methods 48 | * [`CreateBucket`][createbucket] 49 | * [`DeleteBucket`][deletebucket] 50 | * [`HeadBucket`][headbucket] 51 | * Basic methods 52 | * [`HeadObject`][headobject] 53 | * [`GetObject`][getobject] 54 | * [`PutObject`][putobject] 55 | * [`DeleteObject`][deleteobject] 56 | * [`DeleteObjects`][deleteobjects] 57 | * [`ListObjectsV2`][listobjectsv2] 58 | * Multipart upload 59 | * [`CreateMultipartUpload`][completemultipart] 60 | * [`UploadPart`][uploadpart] 61 | * [`ListParts`][listparts] 62 | * [`CompleteMultipartUpload`][completemultipart] 63 | * [`AbortMultipartUpload`][abortmultipart] 64 | 65 | [abortmultipart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html 66 | [completemultipart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html 67 | [listparts]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html 68 | [createbucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html 69 | [deletebucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html 70 | [headbucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html 71 | [createmultipart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html 72 | [deleteobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html 73 | [deleteobjects]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html 74 | [getobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html 75 | [headobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html 76 | [listobjectsv2]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html 77 | [putobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html 78 | [uploadpart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart 79 | 80 | ### Development 81 | 82 | ```zsh 83 | docker run -p 9000:9000 -p 9001:9001 minio/minio:RELEASE.2024-12-18T13-15-44Z server /data --console-address ":9001" 84 | ``` 85 | 86 | In another terminal 87 | ```zsh 88 | cargo test --lib # or other specific command 89 | ``` 90 | -------------------------------------------------------------------------------- /benches/actions.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 4 | 5 | use rusty_s3::{Bucket, Credentials, S3Action as _, UrlStyle}; 6 | 7 | fn criterion_benchmark(c: &mut Criterion) { 8 | c.bench_function("Authenticated GetObject", |b| { 9 | let url = "https://s3.amazonaws.com".parse().unwrap(); 10 | let key = "AKIAIOSFODNN7EXAMPLE"; 11 | let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; 12 | let name = "examplebucket"; 13 | let region = "us-east-1"; 14 | 15 | let credentials = Credentials::new(key, secret); 16 | let bucket = Bucket::new(url, UrlStyle::Path, name, region).unwrap(); 17 | 18 | b.iter(|| { 19 | let object = "text.txt"; 20 | let expires_in = Duration::from_secs(60); 21 | 22 | let mut action = bucket.get_object(Some(black_box(&credentials)), black_box(object)); 23 | action 24 | .query_mut() 25 | .insert("response-content-type", "text/plain"); 26 | let url = action.sign(black_box(expires_in)); 27 | let _ = url; 28 | }); 29 | }); 30 | } 31 | 32 | criterion_group!(benches, criterion_benchmark); 33 | criterion_main!(benches); 34 | -------------------------------------------------------------------------------- /examples/create_bucket.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error as StdError; 2 | use std::time::Duration; 3 | 4 | use reqwest::Client; 5 | use rusty_s3::actions::{CreateBucket, S3Action as _}; 6 | use rusty_s3::{Bucket, Credentials, UrlStyle}; 7 | 8 | const ONE_HOUR: Duration = Duration::from_secs(3600); 9 | 10 | #[tokio::main] 11 | async fn main() -> Result<(), Box> { 12 | let client = Client::new(); 13 | 14 | let url = "http://localhost:9000".parse().unwrap(); 15 | let key = "minioadmin"; 16 | let secret = "minioadmin"; 17 | let region = "minio"; 18 | 19 | let bucket = Bucket::new(url, UrlStyle::Path, "test1234", region).unwrap(); 20 | let credential = Credentials::new(key, secret); 21 | 22 | let action = CreateBucket::new(&bucket, &credential); 23 | let signed_url = action.sign(ONE_HOUR); 24 | 25 | client.put(signed_url).send().await?.error_for_status()?; 26 | 27 | Ok(()) 28 | } 29 | -------------------------------------------------------------------------------- /examples/list_objects.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error as StdError; 2 | use std::time::Duration; 3 | 4 | use reqwest::Client; 5 | use rusty_s3::actions::{ListObjectsV2, S3Action as _}; 6 | use rusty_s3::{Bucket, Credentials, UrlStyle}; 7 | 8 | const ONE_HOUR: Duration = Duration::from_secs(3600); 9 | 10 | #[tokio::main] 11 | async fn main() -> Result<(), Box> { 12 | let client = Client::new(); 13 | 14 | let url = "http://localhost:9000".parse().unwrap(); 15 | let key = "minioadmin"; 16 | let secret = "minioadmin"; 17 | let region = "minio"; 18 | 19 | let bucket = Bucket::new(url, UrlStyle::Path, "test", region).unwrap(); 20 | let credential = Credentials::new(key, secret); 21 | 22 | let action = ListObjectsV2::new(&bucket, Some(&credential)); 23 | let signed_url = action.sign(ONE_HOUR); 24 | 25 | let resp = client.get(signed_url).send().await?.error_for_status()?; 26 | let text = resp.text().await?; 27 | 28 | println!("{text}"); 29 | 30 | let parsed = ListObjectsV2::parse_response(&text)?; 31 | println!("{parsed:#?}"); 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /examples/multipart_upload.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error as StdError; 2 | use std::iter; 3 | use std::time::Duration; 4 | 5 | use reqwest::header::ETAG; 6 | use reqwest::Client; 7 | use rusty_s3::actions::{ 8 | CompleteMultipartUpload, CreateMultipartUpload, S3Action as _, UploadPart, 9 | }; 10 | use rusty_s3::{Bucket, Credentials, UrlStyle}; 11 | 12 | const ONE_HOUR: Duration = Duration::from_secs(3600); 13 | 14 | #[tokio::main] 15 | async fn main() -> Result<(), Box> { 16 | let client = Client::new(); 17 | 18 | let url = "http://localhost:9000".parse().unwrap(); 19 | let key = "minioadmin"; 20 | let secret = "minioadmin"; 21 | let region = "minio"; 22 | 23 | let bucket = Bucket::new(url, UrlStyle::Path, "test", region).unwrap(); 24 | let credential = Credentials::new(key, secret); 25 | 26 | let action = CreateMultipartUpload::new(&bucket, Some(&credential), "idk.txt"); 27 | let url = action.sign(ONE_HOUR); 28 | let resp = client.post(url).send().await?.error_for_status()?; 29 | let body = resp.text().await?; 30 | 31 | let multipart = CreateMultipartUpload::parse_response(&body)?; 32 | 33 | println!( 34 | "multipart upload created - upload id: {}", 35 | multipart.upload_id() 36 | ); 37 | 38 | let part_upload = UploadPart::new( 39 | &bucket, 40 | Some(&credential), 41 | "idk.txt", 42 | 1, 43 | multipart.upload_id(), 44 | ); 45 | let url = part_upload.sign(ONE_HOUR); 46 | 47 | let body = "123456789"; 48 | let resp = client 49 | .put(url) 50 | .body(body) 51 | .send() 52 | .await? 53 | .error_for_status()?; 54 | let etag = resp 55 | .headers() 56 | .get(ETAG) 57 | .expect("every UploadPart request returns an Etag"); 58 | 59 | println!("etag: {}", etag.to_str().unwrap()); 60 | 61 | let action = CompleteMultipartUpload::new( 62 | &bucket, 63 | Some(&credential), 64 | "idk.txt", 65 | multipart.upload_id(), 66 | iter::once(etag.to_str().unwrap()), 67 | ); 68 | let url = action.sign(ONE_HOUR); 69 | 70 | let resp = client 71 | .post(url) 72 | .body(action.body()) 73 | .send() 74 | .await? 75 | .error_for_status()?; 76 | let body = resp.text().await?; 77 | println!("it worked! {body}"); 78 | 79 | Ok(()) 80 | } 81 | -------------------------------------------------------------------------------- /examples/sign_get.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use rusty_s3::actions::{GetObject, S3Action as _}; 4 | use rusty_s3::{Bucket, Credentials, UrlStyle}; 5 | 6 | const ONE_HOUR: Duration = Duration::from_secs(3600); 7 | 8 | fn main() { 9 | let url = "http://localhost:9000".parse().unwrap(); 10 | let key = "minioadmin"; 11 | let secret = "minioadmin"; 12 | let region = "minio"; 13 | 14 | let bucket = Bucket::new(url, UrlStyle::Path, "test", region).unwrap(); 15 | let credential = Credentials::new(key, secret); 16 | 17 | let mut action = GetObject::new(&bucket, Some(&credential), "img.jpg"); 18 | action 19 | .query_mut() 20 | .insert("response-cache-control", "no-cache, no-store"); 21 | let signed_url = action.sign(ONE_HOUR); 22 | 23 | println!("url: {signed_url}"); 24 | } 25 | -------------------------------------------------------------------------------- /examples/sign_put.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use rusty_s3::actions::{PutObject, S3Action as _}; 4 | use rusty_s3::{Bucket, Credentials, UrlStyle}; 5 | 6 | const ONE_HOUR: Duration = Duration::from_secs(3600); 7 | 8 | fn main() { 9 | let url = "http://localhost:9000".parse().unwrap(); 10 | let key = "minioadmin"; 11 | let secret = "minioadmin"; 12 | let region = "minio"; 13 | 14 | let bucket = Bucket::new(url, UrlStyle::Path, "test123", region).unwrap(); 15 | let credential = Credentials::new(key, secret); 16 | 17 | let action = PutObject::new(&bucket, Some(&credential), "duck.jpg"); 18 | let signed_url = action.sign(ONE_HOUR); 19 | 20 | println!("url: {signed_url}"); 21 | } 22 | -------------------------------------------------------------------------------- /src/actions/create_bucket.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use crate::actions::Method; 7 | use crate::actions::S3Action; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Create a new bucket. 12 | /// 13 | /// Find out more about `CreateBucket` from the [AWS API Reference][api] 14 | /// 15 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html 16 | #[derive(Debug, Clone)] 17 | pub struct CreateBucket<'a> { 18 | bucket: &'a Bucket, 19 | credentials: &'a Credentials, 20 | 21 | query: Map<'a>, 22 | headers: Map<'a>, 23 | } 24 | 25 | impl<'a> CreateBucket<'a> { 26 | #[must_use] 27 | pub const fn new(bucket: &'a Bucket, credentials: &'a Credentials) -> Self { 28 | Self { 29 | bucket, 30 | credentials, 31 | 32 | query: Map::new(), 33 | headers: Map::new(), 34 | } 35 | } 36 | } 37 | 38 | impl<'a> S3Action<'a> for CreateBucket<'a> { 39 | const METHOD: Method = Method::Put; 40 | 41 | fn query_mut(&mut self) -> &mut Map<'a> { 42 | &mut self.query 43 | } 44 | 45 | fn headers_mut(&mut self) -> &mut Map<'a> { 46 | &mut self.headers 47 | } 48 | 49 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 50 | let url = self.bucket.base_url().clone(); 51 | 52 | sign( 53 | time, 54 | Self::METHOD, 55 | url, 56 | self.credentials.key(), 57 | self.credentials.secret(), 58 | self.credentials.token(), 59 | self.bucket.region(), 60 | expires_in.as_secs(), 61 | self.query.iter(), 62 | self.headers.iter(), 63 | ) 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use pretty_assertions::assert_eq; 70 | 71 | use super::*; 72 | use crate::{Bucket, Credentials, UrlStyle}; 73 | 74 | #[test] 75 | fn aws_example() { 76 | // Fri, 24 May 2013 00:00:00 GMT 77 | let date = Timestamp::from_second(1369353600).unwrap(); 78 | let expires_in = Duration::from_secs(86400); 79 | 80 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 81 | let bucket = Bucket::new( 82 | endpoint, 83 | UrlStyle::VirtualHost, 84 | "examplebucket", 85 | "us-east-1", 86 | ) 87 | .unwrap(); 88 | let credentials = Credentials::new( 89 | "AKIAIOSFODNN7EXAMPLE", 90 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 91 | ); 92 | 93 | let action = CreateBucket::new(&bucket, &credentials); 94 | 95 | let url = action.sign_with_time(expires_in, &date); 96 | let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=fb5c8ab11e9fd9d3c54ea0293e1df0820feef6c1f2de12e5fe00636e3f0cf9d2"; 97 | 98 | assert_eq!(expected, url.as_str()); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/actions/delete_bucket.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use crate::actions::Method; 7 | use crate::actions::S3Action; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Delete a bucket. 12 | /// 13 | /// The bucket must be empty before it can be deleted. 14 | /// 15 | /// Find out more about `DeleteBucket` from the [AWS API Reference][api] 16 | /// 17 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html 18 | #[derive(Debug, Clone)] 19 | pub struct DeleteBucket<'a> { 20 | bucket: &'a Bucket, 21 | credentials: &'a Credentials, 22 | 23 | query: Map<'a>, 24 | headers: Map<'a>, 25 | } 26 | 27 | impl<'a> DeleteBucket<'a> { 28 | #[must_use] 29 | pub const fn new(bucket: &'a Bucket, credentials: &'a Credentials) -> Self { 30 | Self { 31 | bucket, 32 | credentials, 33 | 34 | query: Map::new(), 35 | headers: Map::new(), 36 | } 37 | } 38 | } 39 | 40 | impl<'a> S3Action<'a> for DeleteBucket<'a> { 41 | const METHOD: Method = Method::Delete; 42 | 43 | fn query_mut(&mut self) -> &mut Map<'a> { 44 | &mut self.query 45 | } 46 | 47 | fn headers_mut(&mut self) -> &mut Map<'a> { 48 | &mut self.headers 49 | } 50 | 51 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 52 | let url = self.bucket.base_url().clone(); 53 | 54 | sign( 55 | time, 56 | Self::METHOD, 57 | url, 58 | self.credentials.key(), 59 | self.credentials.secret(), 60 | self.credentials.token(), 61 | self.bucket.region(), 62 | expires_in.as_secs(), 63 | self.query.iter(), 64 | self.headers.iter(), 65 | ) 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use pretty_assertions::assert_eq; 72 | 73 | use super::*; 74 | use crate::{Bucket, Credentials, UrlStyle}; 75 | 76 | #[test] 77 | fn aws_example() { 78 | // Fri, 24 May 2013 00:00:00 GMT 79 | let date = Timestamp::from_second(1369353600).unwrap(); 80 | let expires_in = Duration::from_secs(86400); 81 | 82 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 83 | let bucket = Bucket::new( 84 | endpoint, 85 | UrlStyle::VirtualHost, 86 | "examplebucket", 87 | "us-east-1", 88 | ) 89 | .unwrap(); 90 | let credentials = Credentials::new( 91 | "AKIAIOSFODNN7EXAMPLE", 92 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 93 | ); 94 | 95 | let action = DeleteBucket::new(&bucket, &credentials); 96 | 97 | let url = action.sign_with_time(expires_in, &date); 98 | let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=875ca449635876849f9cf1622dc709f1978d82e7f6e067b173e6212e3850a1e9"; 99 | 100 | assert_eq!(expected, url.as_str()); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/actions/delete_object.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use super::S3Action; 7 | use crate::actions::Method; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Delete an object from S3, using a `DELETE` request. 12 | /// 13 | /// Find out more about `DeleteObject` from the [AWS API Reference][api] 14 | /// 15 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html 16 | #[derive(Debug, Clone)] 17 | pub struct DeleteObject<'a> { 18 | bucket: &'a Bucket, 19 | credentials: Option<&'a Credentials>, 20 | object: &'a str, 21 | 22 | query: Map<'a>, 23 | headers: Map<'a>, 24 | } 25 | 26 | impl<'a> DeleteObject<'a> { 27 | #[inline] 28 | #[must_use] 29 | pub const fn new( 30 | bucket: &'a Bucket, 31 | credentials: Option<&'a Credentials>, 32 | object: &'a str, 33 | ) -> Self { 34 | Self { 35 | bucket, 36 | credentials, 37 | object, 38 | 39 | query: Map::new(), 40 | headers: Map::new(), 41 | } 42 | } 43 | } 44 | 45 | impl<'a> S3Action<'a> for DeleteObject<'a> { 46 | const METHOD: Method = Method::Delete; 47 | 48 | fn query_mut(&mut self) -> &mut Map<'a> { 49 | &mut self.query 50 | } 51 | 52 | fn headers_mut(&mut self) -> &mut Map<'a> { 53 | &mut self.headers 54 | } 55 | 56 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 57 | let url = self.bucket.object_url(self.object).unwrap(); 58 | 59 | match self.credentials { 60 | Some(credentials) => sign( 61 | time, 62 | Self::METHOD, 63 | url, 64 | credentials.key(), 65 | credentials.secret(), 66 | credentials.token(), 67 | self.bucket.region(), 68 | expires_in.as_secs(), 69 | self.query.iter(), 70 | self.headers.iter(), 71 | ), 72 | None => url, 73 | } 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use pretty_assertions::assert_eq; 80 | 81 | use super::*; 82 | use crate::{Bucket, Credentials, UrlStyle}; 83 | 84 | #[test] 85 | fn aws_example() { 86 | // Fri, 24 May 2013 00:00:00 GMT 87 | let date = Timestamp::from_second(1369353600).unwrap(); 88 | let expires_in = Duration::from_secs(86400); 89 | 90 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 91 | let bucket = Bucket::new( 92 | endpoint, 93 | UrlStyle::VirtualHost, 94 | "examplebucket", 95 | "us-east-1", 96 | ) 97 | .unwrap(); 98 | let credentials = Credentials::new( 99 | "AKIAIOSFODNN7EXAMPLE", 100 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 101 | ); 102 | 103 | let action = DeleteObject::new(&bucket, Some(&credentials), "test.txt"); 104 | 105 | let url = action.sign_with_time(expires_in, &date); 106 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=fb580faa6736a3af12ad5f9c3f1eea783af940a06f6a3de9dadb5679ca25cbfe"; 107 | 108 | assert_eq!(expected, url.as_str()); 109 | } 110 | 111 | #[test] 112 | fn anonymous_custom_query() { 113 | let expires_in = Duration::from_secs(86400); 114 | 115 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 116 | let bucket = Bucket::new( 117 | endpoint, 118 | UrlStyle::VirtualHost, 119 | "examplebucket", 120 | "us-east-1", 121 | ) 122 | .unwrap(); 123 | 124 | let action = DeleteObject::new(&bucket, None, "test.txt"); 125 | let url = action.sign(expires_in); 126 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt"; 127 | 128 | assert_eq!(expected, url.as_str()); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/actions/delete_objects.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | use std::time::Duration; 3 | 4 | use jiff::Timestamp; 5 | use md5::{Digest as _, Md5}; 6 | use serde::Serialize; 7 | use url::Url; 8 | 9 | use crate::actions::Method; 10 | use crate::actions::S3Action; 11 | use crate::signing::sign; 12 | use crate::sorting_iter::SortingIterator; 13 | use crate::{Bucket, Credentials, Map}; 14 | 15 | /// Delete multiple objects from a bucket using a single `POST` request. 16 | /// 17 | /// Find out more about `DeleteObjects` from the [AWS API Reference][api] 18 | /// 19 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html 20 | #[derive(Debug, Clone)] 21 | pub struct DeleteObjects<'a, I> { 22 | bucket: &'a Bucket, 23 | credentials: Option<&'a Credentials>, 24 | objects: I, 25 | quiet: bool, 26 | 27 | query: Map<'a>, 28 | headers: Map<'a>, 29 | } 30 | 31 | impl<'a, I> DeleteObjects<'a, I> { 32 | #[inline] 33 | pub const fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>, objects: I) -> Self { 34 | Self { 35 | bucket, 36 | credentials, 37 | objects, 38 | quiet: false, 39 | query: Map::new(), 40 | headers: Map::new(), 41 | } 42 | } 43 | 44 | pub const fn quiet(&self) -> bool { 45 | self.quiet 46 | } 47 | 48 | pub fn set_quiet(&mut self, quiet: bool) { 49 | self.quiet = quiet; 50 | } 51 | } 52 | 53 | #[derive(Debug, Clone, Default)] 54 | pub struct ObjectIdentifier { 55 | pub key: String, 56 | pub version_id: Option, 57 | } 58 | 59 | impl ObjectIdentifier { 60 | #[must_use] 61 | pub fn new(key: String) -> Self { 62 | Self { 63 | key, 64 | ..Default::default() 65 | } 66 | } 67 | } 68 | 69 | impl<'a, I> DeleteObjects<'a, I> 70 | where 71 | I: Iterator, 72 | { 73 | /// Generate the XML body for the request. 74 | /// 75 | /// # Panics 76 | /// 77 | /// Panics if an index is not representable as a `u16`. 78 | pub fn body_with_md5(self) -> (String, String) { 79 | #[derive(Serialize)] 80 | #[serde(rename = "Delete")] 81 | struct DeleteSerde<'a> { 82 | #[serde(rename = "Object")] 83 | objects: Vec>, 84 | #[serde(rename = "Quiet")] 85 | quiet: Option, 86 | } 87 | #[derive(Serialize)] 88 | #[serde(rename = "Delete")] 89 | struct Object<'a> { 90 | #[serde(rename = "$value")] 91 | nodes: Vec>, 92 | } 93 | 94 | #[derive(Serialize)] 95 | enum Node<'a> { 96 | Key(&'a str), 97 | VersionId(&'a str), 98 | } 99 | 100 | let objects: Vec> = self 101 | .objects 102 | .map(|o| { 103 | let mut nodes = vec![Node::Key(o.key.as_str())]; 104 | if let Some(version_id) = &o.version_id { 105 | nodes.push(Node::VersionId(version_id.as_str())); 106 | } 107 | Object { nodes } 108 | }) 109 | .collect(); 110 | 111 | let req = DeleteSerde { 112 | objects, 113 | quiet: self.quiet.then_some(true), 114 | }; 115 | 116 | let body = quick_xml::se::to_string(&req).unwrap(); 117 | 118 | let content_md5 = crate::base64::encode(Md5::digest(body.as_bytes())); 119 | (body, content_md5) 120 | } 121 | } 122 | 123 | impl<'a, I> S3Action<'a> for DeleteObjects<'a, I> 124 | where 125 | I: Iterator, 126 | { 127 | const METHOD: Method = Method::Post; 128 | 129 | fn query_mut(&mut self) -> &mut Map<'a> { 130 | &mut self.query 131 | } 132 | 133 | fn headers_mut(&mut self) -> &mut Map<'a> { 134 | &mut self.headers 135 | } 136 | 137 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 138 | let url = self.bucket.base_url().clone(); 139 | let query = SortingIterator::new(iter::once(("delete", "1")), self.query.iter()); 140 | 141 | match self.credentials { 142 | Some(credentials) => sign( 143 | time, 144 | Self::METHOD, 145 | url, 146 | credentials.key(), 147 | credentials.secret(), 148 | credentials.token(), 149 | self.bucket.region(), 150 | expires_in.as_secs(), 151 | query, 152 | self.headers.iter(), 153 | ), 154 | None => crate::signing::util::add_query_params(url, query), 155 | } 156 | } 157 | } 158 | 159 | #[cfg(test)] 160 | mod tests { 161 | use pretty_assertions::assert_eq; 162 | 163 | use crate::{Bucket, Credentials, UrlStyle}; 164 | 165 | use super::*; 166 | 167 | #[test] 168 | fn aws_example() { 169 | // Fri, 24 May 2013 00:00:00 GMT 170 | let date = Timestamp::from_second(1369353600).unwrap(); 171 | let expires_in = Duration::from_secs(86400); 172 | 173 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 174 | let bucket = Bucket::new( 175 | endpoint, 176 | UrlStyle::VirtualHost, 177 | "examplebucket", 178 | "us-east-1", 179 | ) 180 | .unwrap(); 181 | let credentials = Credentials::new( 182 | "AKIAIOSFODNN7EXAMPLE", 183 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 184 | ); 185 | 186 | let objects = [ 187 | ObjectIdentifier { 188 | key: "123".to_owned(), 189 | ..Default::default() 190 | }, 191 | ObjectIdentifier { 192 | key: "456".to_owned(), 193 | version_id: Some("ver1234".to_owned()), 194 | }, 195 | ]; 196 | let action = DeleteObjects::new(&bucket, Some(&credentials), objects.iter()); 197 | 198 | let url = action.sign_with_time(expires_in, &date); 199 | let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&delete=1&X-Amz-Signature=0e6170ba8cb7873da76b7fb63638658607f484265935099b3d8cea5195af843c"; 200 | 201 | assert_eq!(expected, url.as_str()); 202 | } 203 | 204 | #[test] 205 | fn anonymous_custom_query() { 206 | let expires_in = Duration::from_secs(86400); 207 | 208 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 209 | let bucket = Bucket::new( 210 | endpoint, 211 | UrlStyle::VirtualHost, 212 | "examplebucket", 213 | "us-east-1", 214 | ) 215 | .unwrap(); 216 | 217 | let objects = [ 218 | ObjectIdentifier { 219 | key: "123".to_owned(), 220 | ..Default::default() 221 | }, 222 | ObjectIdentifier { 223 | key: "456".to_owned(), 224 | version_id: Some("ver1234".to_owned()), 225 | }, 226 | ]; 227 | let action = DeleteObjects::new(&bucket, None, objects.iter()); 228 | let url = action.sign(expires_in); 229 | let expected = "https://examplebucket.s3.amazonaws.com/?delete=1"; 230 | 231 | assert_eq!(expected, url.as_str()); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/actions/get_bucket_policy.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | use std::time::Duration; 3 | 4 | use jiff::Timestamp; 5 | use serde::Deserialize; 6 | use url::Url; 7 | 8 | use super::S3Action; 9 | use crate::actions::Method; 10 | use crate::signing::sign; 11 | use crate::sorting_iter::SortingIterator; 12 | use crate::{Bucket, Credentials, Map}; 13 | 14 | const POLICY_PARAM: &str = "policy"; 15 | 16 | /// Retrieve a bucket's policy from S3. 17 | /// 18 | /// Find out more about `GetBucketPolicy` from the [AWS API Reference][api] 19 | /// 20 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketPolicy.html 21 | #[derive(Debug, Clone)] 22 | pub struct GetBucketPolicy<'a> { 23 | bucket: &'a Bucket, 24 | credentials: Option<&'a Credentials>, 25 | 26 | query: Map<'a>, 27 | headers: Map<'a>, 28 | } 29 | 30 | #[allow(clippy::module_name_repetitions)] 31 | #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 32 | pub struct GetBucketPolicyResponse { 33 | #[serde(rename = "Version")] 34 | pub version: String, 35 | #[serde(rename = "Id")] 36 | pub id: Option, 37 | } 38 | 39 | impl<'a> GetBucketPolicy<'a> { 40 | #[inline] 41 | #[must_use] 42 | pub const fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>) -> Self { 43 | Self { 44 | bucket, 45 | credentials, 46 | 47 | query: Map::new(), 48 | headers: Map::new(), 49 | } 50 | } 51 | 52 | /// Parse the response from S3. 53 | /// 54 | /// # Errors 55 | /// 56 | /// If the response cannot be parsed. 57 | pub fn parse_response(s: &str) -> Result { 58 | serde_json::from_str(s) 59 | } 60 | } 61 | 62 | impl<'a> S3Action<'a> for GetBucketPolicy<'a> { 63 | const METHOD: Method = Method::Get; 64 | 65 | fn query_mut(&mut self) -> &mut Map<'a> { 66 | &mut self.query 67 | } 68 | 69 | fn headers_mut(&mut self) -> &mut Map<'a> { 70 | &mut self.headers 71 | } 72 | 73 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 74 | let url = self.bucket.base_url().clone(); 75 | let query = SortingIterator::new(iter::once((POLICY_PARAM, "")), self.query.iter()); 76 | 77 | match self.credentials { 78 | Some(credentials) => sign( 79 | time, 80 | Self::METHOD, 81 | url, 82 | credentials.key(), 83 | credentials.secret(), 84 | credentials.token(), 85 | self.bucket.region(), 86 | expires_in.as_secs(), 87 | query, 88 | self.headers.iter(), 89 | ), 90 | None => crate::signing::util::add_query_params(url, self.query.iter()), 91 | } 92 | } 93 | } 94 | 95 | #[cfg(test)] 96 | mod tests { 97 | use pretty_assertions::assert_eq; 98 | 99 | use super::*; 100 | 101 | #[test] 102 | fn aws_example() -> Result<(), serde_json::Error> { 103 | assert_eq!( 104 | GetBucketPolicy::parse_response(r#"{"Version":"1"}"#)?, 105 | GetBucketPolicyResponse { 106 | version: "1".to_string(), 107 | id: None 108 | } 109 | ); 110 | 111 | let content = r#"{ 112 | "Version":"2008-10-17", 113 | "Id":"aaaa-bbbb-cccc-dddd", 114 | "Statement" : [ 115 | { 116 | "Effect":"Deny", 117 | "Sid":"1", 118 | "Principal" : { 119 | "AWS":["111122223333","444455556666"] 120 | }, 121 | "Action":["s3:*"], 122 | "Resource":"arn:aws:s3:::bucket/*" 123 | } 124 | ] 125 | } 126 | "#; 127 | assert_eq!( 128 | GetBucketPolicy::parse_response(content)?, 129 | GetBucketPolicyResponse { 130 | version: "2008-10-17".to_string(), 131 | id: Some("aaaa-bbbb-cccc-dddd".to_string()), 132 | } 133 | ); 134 | Ok(()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/actions/get_object.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use super::S3Action; 7 | use crate::actions::Method; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Retrieve an object from S3, using a `GET` request. 12 | /// 13 | /// Find out more about `GetObject` from the [AWS API Reference][api] 14 | /// 15 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html 16 | #[derive(Debug, Clone)] 17 | pub struct GetObject<'a> { 18 | bucket: &'a Bucket, 19 | credentials: Option<&'a Credentials>, 20 | object: &'a str, 21 | 22 | query: Map<'a>, 23 | headers: Map<'a>, 24 | } 25 | 26 | impl<'a> GetObject<'a> { 27 | #[inline] 28 | #[must_use] 29 | pub const fn new( 30 | bucket: &'a Bucket, 31 | credentials: Option<&'a Credentials>, 32 | object: &'a str, 33 | ) -> Self { 34 | Self { 35 | bucket, 36 | credentials, 37 | object, 38 | 39 | query: Map::new(), 40 | headers: Map::new(), 41 | } 42 | } 43 | } 44 | 45 | impl<'a> S3Action<'a> for GetObject<'a> { 46 | const METHOD: Method = Method::Get; 47 | 48 | fn query_mut(&mut self) -> &mut Map<'a> { 49 | &mut self.query 50 | } 51 | 52 | fn headers_mut(&mut self) -> &mut Map<'a> { 53 | &mut self.headers 54 | } 55 | 56 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 57 | let url = self.bucket.object_url(self.object).unwrap(); 58 | 59 | match self.credentials { 60 | Some(credentials) => sign( 61 | time, 62 | Self::METHOD, 63 | url, 64 | credentials.key(), 65 | credentials.secret(), 66 | credentials.token(), 67 | self.bucket.region(), 68 | expires_in.as_secs(), 69 | self.query.iter(), 70 | self.headers.iter(), 71 | ), 72 | None => crate::signing::util::add_query_params(url, self.query.iter()), 73 | } 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use pretty_assertions::assert_eq; 80 | 81 | use super::*; 82 | use crate::{Bucket, Credentials, UrlStyle}; 83 | 84 | #[test] 85 | fn aws_example() { 86 | // Fri, 24 May 2013 00:00:00 GMT 87 | let date = Timestamp::from_second(1369353600).unwrap(); 88 | let expires_in = Duration::from_secs(86400); 89 | 90 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 91 | let bucket = Bucket::new( 92 | endpoint, 93 | UrlStyle::VirtualHost, 94 | "examplebucket", 95 | "us-east-1", 96 | ) 97 | .unwrap(); 98 | let credentials = Credentials::new( 99 | "AKIAIOSFODNN7EXAMPLE", 100 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 101 | ); 102 | 103 | let action = GetObject::new(&bucket, Some(&credentials), "test.txt"); 104 | 105 | let url = action.sign_with_time(expires_in, &date); 106 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"; 107 | 108 | assert_eq!(expected, url.as_str()); 109 | } 110 | 111 | #[test] 112 | fn aws_example_custom_query() { 113 | // Fri, 24 May 2013 00:00:00 GMT 114 | let date = Timestamp::from_second(1369353600).unwrap(); 115 | let expires_in = Duration::from_secs(86400); 116 | 117 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 118 | let bucket = Bucket::new( 119 | endpoint, 120 | UrlStyle::VirtualHost, 121 | "examplebucket", 122 | "us-east-1", 123 | ) 124 | .unwrap(); 125 | let credentials = Credentials::new( 126 | "AKIAIOSFODNN7EXAMPLE", 127 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 128 | ); 129 | 130 | let mut action = GetObject::new(&bucket, Some(&credentials), "test.txt"); 131 | action 132 | .query_mut() 133 | .insert("response-content-type", "text/plain"); 134 | 135 | let url = action.sign_with_time(expires_in, &date); 136 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&response-content-type=text%2Fplain&X-Amz-Signature=9cee3ba363b3a52fed152d18bb250d52a459d0905600d9b032825a3794ffd2cb"; 137 | 138 | assert_eq!(expected, url.as_str()); 139 | } 140 | 141 | #[test] 142 | fn anonymous_custom_query() { 143 | let expires_in = Duration::from_secs(86400); 144 | 145 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 146 | let bucket = Bucket::new( 147 | endpoint, 148 | UrlStyle::VirtualHost, 149 | "examplebucket", 150 | "us-east-1", 151 | ) 152 | .unwrap(); 153 | 154 | let mut action = GetObject::new(&bucket, None, "test.txt"); 155 | action 156 | .query_mut() 157 | .insert("response-content-type", "text/plain"); 158 | 159 | let url = action.sign(expires_in); 160 | let expected = 161 | "https://examplebucket.s3.amazonaws.com/test.txt?response-content-type=text%2Fplain"; 162 | 163 | assert_eq!(expected, url.as_str()); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/actions/head_bucket.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use super::S3Action; 7 | use crate::actions::Method; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Retrieve an bucket's metadata from S3, using a `HEAD` request. 12 | /// 13 | /// Find out more about `HeadBucket` from the [AWS API Reference][api] 14 | /// 15 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html 16 | #[derive(Debug, Clone)] 17 | pub struct HeadBucket<'a> { 18 | bucket: &'a Bucket, 19 | credentials: Option<&'a Credentials>, 20 | 21 | query: Map<'a>, 22 | headers: Map<'a>, 23 | } 24 | 25 | impl<'a> HeadBucket<'a> { 26 | #[inline] 27 | #[must_use] 28 | pub const fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>) -> Self { 29 | Self { 30 | bucket, 31 | credentials, 32 | 33 | query: Map::new(), 34 | headers: Map::new(), 35 | } 36 | } 37 | } 38 | 39 | impl<'a> S3Action<'a> for HeadBucket<'a> { 40 | const METHOD: Method = Method::Head; 41 | 42 | fn query_mut(&mut self) -> &mut Map<'a> { 43 | &mut self.query 44 | } 45 | 46 | fn headers_mut(&mut self) -> &mut Map<'a> { 47 | &mut self.headers 48 | } 49 | 50 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 51 | let url = self.bucket.base_url().clone(); 52 | 53 | match self.credentials { 54 | Some(credentials) => sign( 55 | time, 56 | Self::METHOD, 57 | url, 58 | credentials.key(), 59 | credentials.secret(), 60 | credentials.token(), 61 | self.bucket.region(), 62 | expires_in.as_secs(), 63 | self.query.iter(), 64 | self.headers.iter(), 65 | ), 66 | None => crate::signing::util::add_query_params(url, self.query.iter()), 67 | } 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use pretty_assertions::assert_eq; 74 | 75 | use super::*; 76 | use crate::{Bucket, Credentials, UrlStyle}; 77 | 78 | #[test] 79 | fn aws_example() { 80 | // Fri, 24 May 2013 00:00:00 GMT 81 | let date = Timestamp::from_second(1369353600).unwrap(); 82 | let expires_in = Duration::from_secs(86400); 83 | 84 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 85 | let bucket = Bucket::new( 86 | endpoint, 87 | UrlStyle::VirtualHost, 88 | "examplebucket", 89 | "us-east-1", 90 | ) 91 | .unwrap(); 92 | let credentials = Credentials::new( 93 | "AKIAIOSFODNN7EXAMPLE", 94 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 95 | ); 96 | 97 | let action = HeadBucket::new(&bucket, Some(&credentials)); 98 | 99 | let url = action.sign_with_time(expires_in, &date); 100 | 101 | let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=97f0c782bfd320e7b75026ed746d7e0c759da7b6bf12ed485bbfef4530c16191"; 102 | 103 | assert_eq!(expected, url.as_str()); 104 | } 105 | 106 | #[test] 107 | fn aws_example_custom_query() { 108 | // Fri, 24 May 2013 00:00:00 GMT 109 | let date = Timestamp::from_second(1369353600).unwrap(); 110 | let expires_in = Duration::from_secs(86400); 111 | 112 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 113 | let bucket = Bucket::new( 114 | endpoint, 115 | UrlStyle::VirtualHost, 116 | "examplebucket", 117 | "us-east-1", 118 | ) 119 | .unwrap(); 120 | let credentials = Credentials::new( 121 | "AKIAIOSFODNN7EXAMPLE", 122 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 123 | ); 124 | 125 | let mut action = HeadBucket::new(&bucket, Some(&credentials)); 126 | action 127 | .query_mut() 128 | .insert("response-content-type", "text/plain"); 129 | 130 | let url = action.sign_with_time(expires_in, &date); 131 | let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&response-content-type=text%2Fplain&X-Amz-Signature=1f567b0987313c6ed9c0e92e4e3b70590f96e836b91033f659e6457bfa82dcd0"; 132 | 133 | assert_eq!(expected, url.as_str()); 134 | } 135 | 136 | #[test] 137 | fn anonymous_custom_query() { 138 | let expires_in = Duration::from_secs(86400); 139 | 140 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 141 | let bucket = Bucket::new( 142 | endpoint, 143 | UrlStyle::VirtualHost, 144 | "examplebucket", 145 | "us-east-1", 146 | ) 147 | .unwrap(); 148 | 149 | let mut action = HeadBucket::new(&bucket, None); 150 | action 151 | .query_mut() 152 | .insert("response-content-type", "text/plain"); 153 | 154 | let url = action.sign(expires_in); 155 | let expected = "https://examplebucket.s3.amazonaws.com/?response-content-type=text%2Fplain"; 156 | 157 | assert_eq!(expected, url.as_str()); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/actions/head_object.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use super::S3Action; 7 | use crate::actions::Method; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Retrieve an object's metadata from S3, using a `HEAD` request. 12 | /// 13 | /// Find out more about `HeadObject` from the [AWS API Reference][api] 14 | /// 15 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html 16 | #[derive(Debug, Clone)] 17 | pub struct HeadObject<'a> { 18 | bucket: &'a Bucket, 19 | credentials: Option<&'a Credentials>, 20 | object: &'a str, 21 | 22 | query: Map<'a>, 23 | headers: Map<'a>, 24 | } 25 | 26 | impl<'a> HeadObject<'a> { 27 | #[inline] 28 | #[must_use] 29 | pub const fn new( 30 | bucket: &'a Bucket, 31 | credentials: Option<&'a Credentials>, 32 | object: &'a str, 33 | ) -> Self { 34 | Self { 35 | bucket, 36 | credentials, 37 | object, 38 | 39 | query: Map::new(), 40 | headers: Map::new(), 41 | } 42 | } 43 | } 44 | 45 | impl<'a> S3Action<'a> for HeadObject<'a> { 46 | const METHOD: Method = Method::Head; 47 | 48 | fn query_mut(&mut self) -> &mut Map<'a> { 49 | &mut self.query 50 | } 51 | 52 | fn headers_mut(&mut self) -> &mut Map<'a> { 53 | &mut self.headers 54 | } 55 | 56 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 57 | let url = self.bucket.object_url(self.object).unwrap(); 58 | 59 | match self.credentials { 60 | Some(credentials) => sign( 61 | time, 62 | Self::METHOD, 63 | url, 64 | credentials.key(), 65 | credentials.secret(), 66 | credentials.token(), 67 | self.bucket.region(), 68 | expires_in.as_secs(), 69 | self.query.iter(), 70 | self.headers.iter(), 71 | ), 72 | None => crate::signing::util::add_query_params(url, self.query.iter()), 73 | } 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use pretty_assertions::assert_eq; 80 | 81 | use super::*; 82 | use crate::{Bucket, Credentials, UrlStyle}; 83 | 84 | #[test] 85 | fn aws_example() { 86 | // Fri, 24 May 2013 00:00:00 GMT 87 | let date = Timestamp::from_second(1369353600).unwrap(); 88 | let expires_in = Duration::from_secs(86400); 89 | 90 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 91 | let bucket = Bucket::new( 92 | endpoint, 93 | UrlStyle::VirtualHost, 94 | "examplebucket", 95 | "us-east-1", 96 | ) 97 | .unwrap(); 98 | let credentials = Credentials::new( 99 | "AKIAIOSFODNN7EXAMPLE", 100 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 101 | ); 102 | 103 | let action = HeadObject::new(&bucket, Some(&credentials), "test.txt"); 104 | 105 | let url = action.sign_with_time(expires_in, &date); 106 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=f9c58dec0c3cada1e6f133547c7b6b2ef9d7df87447a785ad1b23079005271e5"; 107 | 108 | assert_eq!(expected, url.as_str()); 109 | } 110 | 111 | #[test] 112 | fn aws_example_custom_query() { 113 | // Fri, 24 May 2013 00:00:00 GMT 114 | let date = Timestamp::from_second(1369353600).unwrap(); 115 | let expires_in = Duration::from_secs(86400); 116 | 117 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 118 | let bucket = Bucket::new( 119 | endpoint, 120 | UrlStyle::VirtualHost, 121 | "examplebucket", 122 | "us-east-1", 123 | ) 124 | .unwrap(); 125 | let credentials = Credentials::new( 126 | "AKIAIOSFODNN7EXAMPLE", 127 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 128 | ); 129 | 130 | let mut action = HeadObject::new(&bucket, Some(&credentials), "test.txt"); 131 | action 132 | .query_mut() 133 | .insert("response-content-type", "text/plain"); 134 | 135 | let url = action.sign_with_time(expires_in, &date); 136 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&response-content-type=text%2Fplain&X-Amz-Signature=cbdb1e433786bd2f0dc61c3ad4d3a32687c9a1a7e8c6ee170a2ea805c59247f9"; 137 | 138 | assert_eq!(expected, url.as_str()); 139 | } 140 | 141 | #[test] 142 | fn anonymous_custom_query() { 143 | let expires_in = Duration::from_secs(86400); 144 | 145 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 146 | let bucket = Bucket::new( 147 | endpoint, 148 | UrlStyle::VirtualHost, 149 | "examplebucket", 150 | "us-east-1", 151 | ) 152 | .unwrap(); 153 | 154 | let mut action = HeadObject::new(&bucket, None, "test.txt"); 155 | action 156 | .query_mut() 157 | .insert("response-content-type", "text/plain"); 158 | 159 | let url = action.sign(expires_in); 160 | let expected = 161 | "https://examplebucket.s3.amazonaws.com/test.txt?response-content-type=text%2Fplain"; 162 | 163 | assert_eq!(expected, url.as_str()); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/actions/list_objects_v2.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::io::{BufReader, Read}; 3 | use std::time::Duration; 4 | 5 | use jiff::Timestamp; 6 | use serde::Deserialize; 7 | use url::Url; 8 | 9 | use crate::actions::Method; 10 | use crate::actions::S3Action; 11 | use crate::signing::sign; 12 | use crate::{Bucket, Credentials, Map}; 13 | 14 | /// List all objects in the bucket. 15 | /// 16 | /// If `next_continuation_token` is `Some` the response is truncated, and the 17 | /// rest of the list can be retrieved by reusing the `ListObjectV2` action 18 | /// but with `continuation-token` set to the value of `next_continuation_token` 19 | /// received in the previous response. 20 | /// 21 | /// Find out more about `ListObjectsV2` from the [AWS API Reference][api] 22 | /// 23 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html 24 | #[allow(clippy::module_name_repetitions)] 25 | #[derive(Debug, Clone)] 26 | pub struct ListObjectsV2<'a> { 27 | bucket: &'a Bucket, 28 | credentials: Option<&'a Credentials>, 29 | 30 | query: Map<'a>, 31 | headers: Map<'a>, 32 | } 33 | 34 | #[allow(clippy::module_name_repetitions)] 35 | #[derive(Debug, Clone, Deserialize)] 36 | pub struct ListObjectsV2Response { 37 | // #[serde(rename = "IsTruncated")] 38 | // is_truncated: bool, 39 | #[serde(rename = "Contents")] 40 | #[serde(default)] 41 | pub contents: Vec, 42 | 43 | // #[serde(rename = "Name")] 44 | // name: String, 45 | // #[serde(rename = "Prefix")] 46 | // prefix: String, 47 | // #[serde(rename = "Delimiter")] 48 | // delimiter: String, 49 | #[serde(rename = "MaxKeys")] 50 | pub max_keys: Option, 51 | #[serde(rename = "CommonPrefixes", default)] 52 | pub common_prefixes: Vec, 53 | // #[serde(rename = "EncodingType")] 54 | // encoding_type: String, 55 | // #[serde(rename = "KeyCount")] 56 | // key_count: u16, 57 | // #[serde(rename = "ContinuationToken")] 58 | // continuation_token: Option, 59 | #[serde(rename = "NextContinuationToken")] 60 | pub next_continuation_token: Option, 61 | #[serde(rename = "StartAfter")] 62 | pub start_after: Option, 63 | } 64 | 65 | #[derive(Debug, Clone, Deserialize)] 66 | pub struct ListObjectsContent { 67 | #[serde(rename = "ETag")] 68 | pub etag: String, 69 | #[serde(rename = "Key")] 70 | pub key: String, 71 | #[serde(rename = "LastModified")] 72 | pub last_modified: String, 73 | #[serde(rename = "Owner")] 74 | pub owner: Option, 75 | #[serde(rename = "Size")] 76 | pub size: u64, 77 | #[serde(rename = "StorageClass")] 78 | pub storage_class: Option, 79 | } 80 | 81 | #[derive(Debug, Clone, Deserialize)] 82 | pub struct ListObjectsOwner { 83 | #[serde(rename = "ID")] 84 | pub id: String, 85 | #[serde(rename = "DisplayName")] 86 | pub display_name: String, 87 | } 88 | 89 | #[derive(Debug, Clone, Deserialize)] 90 | pub struct CommonPrefixes { 91 | #[serde(rename = "Prefix")] 92 | pub prefix: String, 93 | } 94 | 95 | impl<'a> ListObjectsV2<'a> { 96 | #[must_use] 97 | pub fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>) -> Self { 98 | let mut query = Map::new(); 99 | query.insert("list-type", "2"); 100 | query.insert("encoding-type", "url"); 101 | 102 | Self { 103 | bucket, 104 | credentials, 105 | 106 | query, 107 | headers: Map::new(), 108 | } 109 | } 110 | 111 | /// Limits the response to keys that begin with the specified prefix. 112 | /// 113 | /// See for more infos. 114 | /// # Example 115 | /// ``` 116 | /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap(); 117 | /// let mut list = bucket.list_objects_v2(None); 118 | /// list.with_prefix("tamo"); 119 | /// ``` 120 | pub fn with_prefix(&mut self, prefix: impl Into>) { 121 | self.query_mut().insert("prefix", prefix); 122 | } 123 | 124 | /// A delimiter is a character that you use to group keys. 125 | /// 126 | /// See for more infos. 127 | /// # Example 128 | /// ``` 129 | /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap(); 130 | /// let mut list = bucket.list_objects_v2(None); 131 | /// list.with_delimiter("/"); 132 | /// ``` 133 | pub fn with_delimiter(&mut self, delimiter: impl Into>) { 134 | self.query_mut().insert("delimiter", delimiter); 135 | } 136 | 137 | /// `StartAfter` is where you want Amazon S3 to start listing from. 138 | /// Amazon S3 starts listing after this specified key. 139 | /// `StartAfter` can be any key in the bucket. 140 | /// 141 | /// See for more infos. 142 | /// # Example 143 | /// ``` 144 | /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap(); 145 | /// let mut list = bucket.list_objects_v2(None); 146 | /// list.with_start_after("tamo"); // <- This token should come from a previous call to the list API. 147 | /// ``` 148 | pub fn with_start_after(&mut self, start_after: impl Into>) { 149 | self.query_mut().insert("start-after", start_after); 150 | } 151 | 152 | /// `ContinuationToken` indicates to Amazon S3 that the list is being continued on this bucket with a token. 153 | /// `ContinuationToken` is obfuscated and is not a real key. 154 | /// 155 | /// See for more infos. 156 | /// # Example 157 | /// ``` 158 | /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap(); 159 | /// let mut list = bucket.list_objects_v2(None); 160 | /// list.with_continuation_token("tamo"); // <- This token should come from a previous call to the list API. 161 | /// ``` 162 | pub fn with_continuation_token(&mut self, continuation_token: impl Into>) { 163 | self.query_mut() 164 | .insert("continuation-token", continuation_token); 165 | } 166 | 167 | /// Sets the maximum number of keys returned in the response. 168 | /// By default, the action returns up to 1,000 key names. 169 | /// The response might contain fewer keys but will never contain more. 170 | /// 171 | /// See for more infos. 172 | /// # Example 173 | /// ``` 174 | /// # let bucket = rusty_s3::Bucket::new(url::Url::parse("http://rusty_s3/").unwrap(), rusty_s3::UrlStyle::Path, "doggo", "doggoland").unwrap(); 175 | /// let mut list = bucket.list_objects_v2(None); 176 | /// list.with_continuation_token("tamo"); // <- This token should come from a previous call to the list API. 177 | /// ``` 178 | pub fn with_max_keys(&mut self, max_keys: usize) { 179 | self.query_mut().insert("max-keys", max_keys.to_string()); 180 | } 181 | 182 | /// Parse the XML response from S3 into a struct. 183 | /// 184 | /// # Errors 185 | /// 186 | /// Returns an error if the XML response could not be parsed. 187 | pub fn parse_response( 188 | s: impl AsRef<[u8]>, 189 | ) -> Result { 190 | Self::parse_response_from_reader(&mut s.as_ref()) 191 | } 192 | 193 | /// Parse the XML response from S3 into a struct. 194 | /// 195 | /// # Errors 196 | /// 197 | /// Returns an error if the XML response could not be parsed. 198 | pub fn parse_response_from_reader( 199 | s: impl Read, 200 | ) -> Result { 201 | let mut parsed: ListObjectsV2Response = quick_xml::de::from_reader(BufReader::new(s))?; 202 | 203 | // S3 returns an Owner with an empty DisplayName and ID when fetch-owner is disabled 204 | for content in &mut parsed.contents { 205 | if let Some(owner) = &content.owner { 206 | if owner.id.is_empty() && owner.display_name.is_empty() { 207 | content.owner = None; 208 | } 209 | } 210 | } 211 | 212 | Ok(parsed) 213 | } 214 | } 215 | 216 | impl<'a> S3Action<'a> for ListObjectsV2<'a> { 217 | const METHOD: Method = Method::Get; 218 | 219 | fn query_mut(&mut self) -> &mut Map<'a> { 220 | &mut self.query 221 | } 222 | 223 | fn headers_mut(&mut self) -> &mut Map<'a> { 224 | &mut self.headers 225 | } 226 | 227 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 228 | let url = self.bucket.base_url().clone(); 229 | 230 | match self.credentials { 231 | Some(credentials) => sign( 232 | time, 233 | Self::METHOD, 234 | url, 235 | credentials.key(), 236 | credentials.secret(), 237 | credentials.token(), 238 | self.bucket.region(), 239 | expires_in.as_secs(), 240 | self.query.iter(), 241 | self.headers.iter(), 242 | ), 243 | None => crate::signing::util::add_query_params(url, self.query.iter()), 244 | } 245 | } 246 | } 247 | 248 | #[cfg(test)] 249 | mod tests { 250 | use pretty_assertions::assert_eq; 251 | 252 | use super::*; 253 | use crate::{Bucket, Credentials, UrlStyle}; 254 | 255 | #[test] 256 | fn aws_example() { 257 | // Fri, 24 May 2013 00:00:00 GMT 258 | let date = Timestamp::from_second(1369353600).unwrap(); 259 | let expires_in = Duration::from_secs(86400); 260 | 261 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 262 | let bucket = Bucket::new( 263 | endpoint, 264 | UrlStyle::VirtualHost, 265 | "examplebucket", 266 | "us-east-1", 267 | ) 268 | .unwrap(); 269 | let credentials = Credentials::new( 270 | "AKIAIOSFODNN7EXAMPLE", 271 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 272 | ); 273 | 274 | let action = ListObjectsV2::new(&bucket, Some(&credentials)); 275 | 276 | let url = action.sign_with_time(expires_in, &date); 277 | let expected = "https://examplebucket.s3.amazonaws.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&encoding-type=url&list-type=2&X-Amz-Signature=58e7f65928710f045f6a7e1f7a32b3426b4895900fad799db66faa3ff8b18bd5"; 278 | 279 | assert_eq!(expected, url.as_str()); 280 | } 281 | 282 | #[test] 283 | fn anonymous_custom_query() { 284 | let expires_in = Duration::from_secs(86400); 285 | 286 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 287 | let bucket = Bucket::new( 288 | endpoint, 289 | UrlStyle::VirtualHost, 290 | "examplebucket", 291 | "us-east-1", 292 | ) 293 | .unwrap(); 294 | 295 | let mut action = ListObjectsV2::new(&bucket, None); 296 | action.query_mut().insert("continuation-token", "duck"); 297 | 298 | let url = action.sign(expires_in); 299 | let expected = "https://examplebucket.s3.amazonaws.com/?continuation-token=duck&encoding-type=url&list-type=2"; 300 | 301 | assert_eq!(expected, url.as_str()); 302 | } 303 | 304 | #[test] 305 | fn parse() { 306 | let input = r#" 307 | 308 | 309 | test 310 | 311 | 3 312 | 4500 313 | 314 | false 315 | 316 | duck.jpg 317 | 2020-12-01T20:43:11.794Z 318 | "bfd537a51d15208163231b0711e0b1f3" 319 | 4274 320 | 321 | 322 | 323 | 324 | STANDARD 325 | 326 | 327 | idk.txt 328 | 2020-12-05T08:23:52.215Z 329 | "5927c5d64d94a5786f90003aa26d0159-1" 330 | 9 331 | 332 | 333 | 334 | 335 | STANDARD 336 | 337 | 338 | img.jpg 339 | 2020-11-26T20:21:35.858Z 340 | "f7dbec93a0932ccb4d0f4e512eb1a443" 341 | 41259 342 | 343 | 344 | 345 | 346 | STANDARD 347 | 348 | url 349 | 350 | "#; 351 | 352 | let parsed = ListObjectsV2::parse_response(input).unwrap(); 353 | assert_eq!(parsed.contents.len(), 3); 354 | 355 | let item_1 = &parsed.contents[0]; 356 | assert_eq!(item_1.etag, "\"bfd537a51d15208163231b0711e0b1f3\""); 357 | assert_eq!(item_1.key, "duck.jpg"); 358 | assert_eq!(item_1.last_modified, "2020-12-01T20:43:11.794Z"); 359 | assert!(item_1.owner.is_none()); 360 | assert_eq!(item_1.size, 4274); 361 | assert_eq!(item_1.storage_class, Some("STANDARD".to_string())); 362 | 363 | let item_2 = &parsed.contents[1]; 364 | assert_eq!(item_2.etag, "\"5927c5d64d94a5786f90003aa26d0159-1\""); 365 | assert_eq!(item_2.key, "idk.txt"); 366 | assert_eq!(item_2.last_modified, "2020-12-05T08:23:52.215Z"); 367 | assert!(item_2.owner.is_none()); 368 | assert_eq!(item_2.size, 9); 369 | assert_eq!(item_2.storage_class, Some("STANDARD".to_string())); 370 | 371 | let item_3 = &parsed.contents[2]; 372 | assert_eq!(item_3.etag, "\"f7dbec93a0932ccb4d0f4e512eb1a443\""); 373 | assert_eq!(item_3.key, "img.jpg"); 374 | assert_eq!(item_3.last_modified, "2020-11-26T20:21:35.858Z"); 375 | assert!(item_3.owner.is_none()); 376 | assert_eq!(item_3.size, 41259); 377 | assert_eq!(item_3.storage_class, Some("STANDARD".to_string())); 378 | 379 | assert_eq!(parsed.max_keys, Some(4500)); 380 | assert!(parsed.common_prefixes.is_empty()); 381 | assert!(parsed.next_continuation_token.is_none()); 382 | assert!(parsed.start_after.is_none()); 383 | } 384 | 385 | #[test] 386 | fn parse_no_contents() { 387 | let input = r#" 388 | 389 | 390 | test 391 | 392 | 0 393 | 4500 394 | 395 | false 396 | url 397 | 398 | "#; 399 | 400 | let parsed = ListObjectsV2::parse_response(input).unwrap(); 401 | assert_eq!(parsed.contents.is_empty(), true); 402 | 403 | assert_eq!(parsed.max_keys, Some(4500)); 404 | assert!(parsed.common_prefixes.is_empty()); 405 | assert!(parsed.next_continuation_token.is_none()); 406 | assert!(parsed.start_after.is_none()); 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/actions/mod.rs: -------------------------------------------------------------------------------- 1 | //! S3 request building and response parsing support 2 | 3 | use std::time::Duration; 4 | 5 | use jiff::Timestamp; 6 | use url::Url; 7 | 8 | pub use self::create_bucket::CreateBucket; 9 | pub use self::delete_bucket::DeleteBucket; 10 | pub use self::delete_object::DeleteObject; 11 | #[cfg(feature = "full")] 12 | pub use self::delete_objects::{DeleteObjects, ObjectIdentifier}; 13 | #[cfg(feature = "full")] 14 | pub use self::get_bucket_policy::{GetBucketPolicy, GetBucketPolicyResponse}; 15 | pub use self::get_object::GetObject; 16 | pub use self::head_bucket::HeadBucket; 17 | pub use self::head_object::HeadObject; 18 | #[cfg(feature = "full")] 19 | #[doc(inline)] 20 | pub use self::list_objects_v2::{ListObjectsV2, ListObjectsV2Response}; 21 | pub use self::multipart_upload::abort::AbortMultipartUpload; 22 | #[cfg(feature = "full")] 23 | pub use self::multipart_upload::complete::CompleteMultipartUpload; 24 | #[cfg(feature = "full")] 25 | pub use self::multipart_upload::create::{CreateMultipartUpload, CreateMultipartUploadResponse}; 26 | #[cfg(feature = "full")] 27 | pub use self::multipart_upload::list_parts::{ListParts, ListPartsResponse}; 28 | pub use self::multipart_upload::upload::UploadPart; 29 | pub use self::put_object::PutObject; 30 | use crate::{Map, Method}; 31 | 32 | mod create_bucket; 33 | mod delete_bucket; 34 | mod delete_object; 35 | #[cfg(feature = "full")] 36 | mod delete_objects; 37 | #[cfg(feature = "full")] 38 | mod get_bucket_policy; 39 | mod get_object; 40 | mod head_bucket; 41 | mod head_object; 42 | #[cfg(feature = "full")] 43 | pub mod list_objects_v2; 44 | mod multipart_upload; 45 | mod put_object; 46 | 47 | /// A request which can be signed 48 | pub trait S3Action<'a> { 49 | const METHOD: Method; 50 | 51 | /// Sign a request for this action, using `METHOD` for the [`Method`] 52 | fn sign(&self, expires_in: Duration) -> Url { 53 | let now = Timestamp::now(); 54 | self.sign_with_time(expires_in, &now) 55 | } 56 | 57 | /// Get a mutable reference to the query string of this action 58 | fn query_mut(&mut self) -> &mut Map<'a>; 59 | 60 | /// Get a mutable reference to the signed headers of this action 61 | /// 62 | /// Headers specified here must also be present in the final request, 63 | /// with the same value specified, otherwise the S3 API will return an error. 64 | fn headers_mut(&mut self) -> &mut Map<'a>; 65 | 66 | /// Takes the time at which the URL should be signed 67 | /// Used for testing purposes 68 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url; 69 | } 70 | -------------------------------------------------------------------------------- /src/actions/multipart_upload/abort.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | use std::time::Duration; 3 | 4 | use jiff::Timestamp; 5 | use url::Url; 6 | 7 | use crate::actions::Method; 8 | use crate::actions::S3Action; 9 | use crate::signing::sign; 10 | use crate::sorting_iter::SortingIterator; 11 | use crate::{Bucket, Credentials, Map}; 12 | 13 | /// Abort multipart upload. 14 | /// 15 | /// This also cleans up any previously uploaded part. 16 | /// 17 | /// Find out more about `AbortMultipartUpload` from the [AWS API Reference][api] 18 | /// 19 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html 20 | #[allow(clippy::module_name_repetitions)] 21 | #[derive(Debug, Clone)] 22 | pub struct AbortMultipartUpload<'a> { 23 | bucket: &'a Bucket, 24 | credentials: Option<&'a Credentials>, 25 | object: &'a str, 26 | 27 | upload_id: &'a str, 28 | 29 | query: Map<'a>, 30 | headers: Map<'a>, 31 | } 32 | 33 | impl<'a> AbortMultipartUpload<'a> { 34 | #[inline] 35 | #[must_use] 36 | pub const fn new( 37 | bucket: &'a Bucket, 38 | credentials: Option<&'a Credentials>, 39 | object: &'a str, 40 | upload_id: &'a str, 41 | ) -> Self { 42 | Self { 43 | bucket, 44 | credentials, 45 | object, 46 | 47 | upload_id, 48 | 49 | query: Map::new(), 50 | headers: Map::new(), 51 | } 52 | } 53 | } 54 | 55 | impl<'a> S3Action<'a> for AbortMultipartUpload<'a> { 56 | const METHOD: Method = Method::Delete; 57 | 58 | fn query_mut(&mut self) -> &mut Map<'a> { 59 | &mut self.query 60 | } 61 | 62 | fn headers_mut(&mut self) -> &mut Map<'a> { 63 | &mut self.headers 64 | } 65 | 66 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 67 | let url = self.bucket.object_url(self.object).unwrap(); 68 | let query = iter::once(("uploadId", self.upload_id)); 69 | 70 | match self.credentials { 71 | Some(credentials) => sign( 72 | time, 73 | Self::METHOD, 74 | url, 75 | credentials.key(), 76 | credentials.secret(), 77 | credentials.token(), 78 | self.bucket.region(), 79 | expires_in.as_secs(), 80 | SortingIterator::new(query, self.query.iter()), 81 | self.headers.iter(), 82 | ), 83 | None => crate::signing::util::add_query_params(url, query), 84 | } 85 | } 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use pretty_assertions::assert_eq; 91 | 92 | use super::*; 93 | use crate::{Bucket, Credentials, UrlStyle}; 94 | 95 | #[test] 96 | fn aws_example() { 97 | // Fri, 24 May 2013 00:00:00 GMT 98 | let date = Timestamp::from_second(1369353600).unwrap(); 99 | let expires_in = Duration::from_secs(86400); 100 | 101 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 102 | let bucket = Bucket::new( 103 | endpoint, 104 | UrlStyle::VirtualHost, 105 | "examplebucket", 106 | "us-east-1", 107 | ) 108 | .unwrap(); 109 | let credentials = Credentials::new( 110 | "AKIAIOSFODNN7EXAMPLE", 111 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 112 | ); 113 | 114 | let action = AbortMultipartUpload::new(&bucket, Some(&credentials), "test.txt", "abcd"); 115 | 116 | let url = action.sign_with_time(expires_in, &date); 117 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&uploadId=abcd&X-Amz-Signature=7670bc768a7cdb5c276a9dddadeefdffb52061f94db6c14b4a9284fdc195bb59"; 118 | 119 | assert_eq!(expected, url.as_str()); 120 | } 121 | 122 | #[test] 123 | fn anonymous_custom_query() { 124 | let expires_in = Duration::from_secs(86400); 125 | 126 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 127 | let bucket = Bucket::new( 128 | endpoint, 129 | UrlStyle::VirtualHost, 130 | "examplebucket", 131 | "us-east-1", 132 | ) 133 | .unwrap(); 134 | 135 | let action = AbortMultipartUpload::new(&bucket, None, "test.txt", "abcd"); 136 | let url = action.sign(expires_in); 137 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?uploadId=abcd"; 138 | 139 | assert_eq!(expected, url.as_str()); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/actions/multipart_upload/complete.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | use std::time::Duration; 3 | 4 | use jiff::Timestamp; 5 | use serde::Serialize; 6 | use url::Url; 7 | 8 | use crate::actions::Method; 9 | use crate::actions::S3Action; 10 | use crate::signing::sign; 11 | use crate::sorting_iter::SortingIterator; 12 | use crate::{Bucket, Credentials, Map}; 13 | 14 | /// Complete a multipart upload. 15 | /// 16 | /// Find out more about `CompleteMultipartUpload` from the [AWS API Reference][api] 17 | /// 18 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html 19 | #[allow(clippy::module_name_repetitions)] 20 | #[derive(Debug, Clone)] 21 | pub struct CompleteMultipartUpload<'a, I> { 22 | bucket: &'a Bucket, 23 | credentials: Option<&'a Credentials>, 24 | object: &'a str, 25 | upload_id: &'a str, 26 | 27 | etags: I, 28 | 29 | query: Map<'a>, 30 | headers: Map<'a>, 31 | } 32 | 33 | impl<'a, I> CompleteMultipartUpload<'a, I> { 34 | #[inline] 35 | pub const fn new( 36 | bucket: &'a Bucket, 37 | credentials: Option<&'a Credentials>, 38 | object: &'a str, 39 | upload_id: &'a str, 40 | etags: I, 41 | ) -> Self { 42 | Self { 43 | bucket, 44 | credentials, 45 | object, 46 | 47 | upload_id, 48 | etags, 49 | 50 | query: Map::new(), 51 | headers: Map::new(), 52 | } 53 | } 54 | } 55 | 56 | impl<'a, I> CompleteMultipartUpload<'a, I> 57 | where 58 | I: Iterator, 59 | { 60 | /// Generate the XML body for the request. 61 | /// 62 | /// # Panics 63 | /// 64 | /// Panics if an index is not representable as a `u16`. 65 | pub fn body(self) -> String { 66 | #[derive(Serialize)] 67 | #[serde(rename = "CompleteMultipartUpload")] 68 | struct CompleteMultipartUploadSerde<'a> { 69 | #[serde(rename = "Part")] 70 | parts: Vec>, 71 | } 72 | 73 | #[derive(Serialize)] 74 | struct Part<'a> { 75 | #[serde(rename = "$value")] 76 | nodes: Vec>, 77 | } 78 | 79 | #[derive(Serialize)] 80 | enum Node<'a> { 81 | ETag(&'a str), 82 | PartNumber(u16), 83 | } 84 | 85 | let parts = self 86 | .etags 87 | .enumerate() 88 | .map(|(i, etag)| Part { 89 | nodes: vec![ 90 | Node::ETag(etag), 91 | Node::PartNumber(u16::try_from(i).expect("convert to u16") + 1), 92 | ], 93 | }) 94 | .collect::>(); 95 | 96 | let req = CompleteMultipartUploadSerde { parts }; 97 | 98 | quick_xml::se::to_string(&req).unwrap() 99 | } 100 | } 101 | 102 | impl<'a, I> S3Action<'a> for CompleteMultipartUpload<'a, I> 103 | where 104 | I: Iterator, 105 | { 106 | const METHOD: Method = Method::Post; 107 | 108 | fn query_mut(&mut self) -> &mut Map<'a> { 109 | &mut self.query 110 | } 111 | 112 | fn headers_mut(&mut self) -> &mut Map<'a> { 113 | &mut self.headers 114 | } 115 | 116 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 117 | let url = self.bucket.object_url(self.object).unwrap(); 118 | let query = iter::once(("uploadId", self.upload_id)); 119 | 120 | match self.credentials { 121 | Some(credentials) => sign( 122 | time, 123 | Self::METHOD, 124 | url, 125 | credentials.key(), 126 | credentials.secret(), 127 | credentials.token(), 128 | self.bucket.region(), 129 | expires_in.as_secs(), 130 | SortingIterator::new(query, self.query.iter()), 131 | self.headers.iter(), 132 | ), 133 | None => crate::signing::util::add_query_params(url, query), 134 | } 135 | } 136 | } 137 | 138 | #[cfg(test)] 139 | mod tests { 140 | use pretty_assertions::assert_eq; 141 | 142 | use super::*; 143 | use crate::{Bucket, Credentials, UrlStyle}; 144 | 145 | #[test] 146 | fn aws_example() { 147 | // Fri, 24 May 2013 00:00:00 GMT 148 | let date = Timestamp::from_second(1369353600).unwrap(); 149 | let expires_in = Duration::from_secs(86400); 150 | 151 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 152 | let bucket = Bucket::new( 153 | endpoint, 154 | UrlStyle::VirtualHost, 155 | "examplebucket", 156 | "us-east-1", 157 | ) 158 | .unwrap(); 159 | let credentials = Credentials::new( 160 | "AKIAIOSFODNN7EXAMPLE", 161 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 162 | ); 163 | 164 | let etags = ["123456789", "abcdef"]; 165 | let action = CompleteMultipartUpload::new( 166 | &bucket, 167 | Some(&credentials), 168 | "test.txt", 169 | "abcd", 170 | etags.iter().copied(), 171 | ); 172 | 173 | let url = action.sign_with_time(expires_in, &date); 174 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&uploadId=abcd&X-Amz-Signature=19b9d341ce3c6ebd9f049882e875dcad4adc493d9d46d55148f4113146c53dd8"; 175 | 176 | assert_eq!(expected, url.as_str()); 177 | 178 | let expected = "1234567891abcdef2"; 179 | assert_eq!(action.body(), expected); 180 | } 181 | 182 | #[test] 183 | fn anonymous_custom_query() { 184 | let expires_in = Duration::from_secs(86400); 185 | 186 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 187 | let bucket = Bucket::new( 188 | endpoint, 189 | UrlStyle::VirtualHost, 190 | "examplebucket", 191 | "us-east-1", 192 | ) 193 | .unwrap(); 194 | 195 | let etags = ["123456789", "abcdef"]; 196 | let action = 197 | CompleteMultipartUpload::new(&bucket, None, "test.txt", "abcd", etags.iter().copied()); 198 | let url = action.sign(expires_in); 199 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?uploadId=abcd"; 200 | 201 | assert_eq!(expected, url.as_str()); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/actions/multipart_upload/create.rs: -------------------------------------------------------------------------------- 1 | use std::io::{BufReader, Read}; 2 | use std::iter; 3 | use std::time::Duration; 4 | 5 | use jiff::Timestamp; 6 | use serde::Deserialize; 7 | use url::Url; 8 | 9 | use crate::actions::Method; 10 | use crate::actions::S3Action; 11 | use crate::signing::sign; 12 | use crate::sorting_iter::SortingIterator; 13 | use crate::{Bucket, Credentials, Map}; 14 | 15 | /// Create a multipart upload. 16 | /// 17 | /// A few advantages of multipart uploads are: 18 | /// 19 | /// * being able to be resume without having to start back from the beginning 20 | /// * parallelize the uploads across multiple threads 21 | /// 22 | /// Find out more about `CreateMultipartUpload` from the [AWS API Reference][api] 23 | /// 24 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html 25 | #[allow(clippy::module_name_repetitions)] 26 | #[derive(Debug, Clone)] 27 | pub struct CreateMultipartUpload<'a> { 28 | bucket: &'a Bucket, 29 | credentials: Option<&'a Credentials>, 30 | object: &'a str, 31 | 32 | query: Map<'a>, 33 | headers: Map<'a>, 34 | } 35 | 36 | #[allow(clippy::module_name_repetitions)] 37 | #[derive(Debug, Clone)] 38 | pub struct CreateMultipartUploadResponse(InnerCreateMultipartUploadResponse); 39 | 40 | #[allow(clippy::module_name_repetitions)] 41 | #[derive(Debug, Clone, Deserialize)] 42 | struct InnerCreateMultipartUploadResponse { 43 | #[serde(rename = "UploadId")] 44 | upload_id: String, 45 | } 46 | 47 | impl<'a> CreateMultipartUpload<'a> { 48 | #[inline] 49 | #[must_use] 50 | pub const fn new( 51 | bucket: &'a Bucket, 52 | credentials: Option<&'a Credentials>, 53 | object: &'a str, 54 | ) -> Self { 55 | Self { 56 | bucket, 57 | credentials, 58 | object, 59 | 60 | query: Map::new(), 61 | headers: Map::new(), 62 | } 63 | } 64 | 65 | /// Parse the XML response from S3 66 | /// 67 | /// # Errors 68 | /// 69 | /// Will return an error if the body is not valid XML 70 | pub fn parse_response( 71 | s: impl AsRef<[u8]>, 72 | ) -> Result { 73 | Self::parse_response_from_reader(&mut s.as_ref()) 74 | } 75 | 76 | /// Parse the XML response from S3 77 | /// 78 | /// # Errors 79 | /// 80 | /// Will return an error if the body is not valid XML 81 | pub fn parse_response_from_reader( 82 | s: impl Read, 83 | ) -> Result { 84 | let parsed = quick_xml::de::from_reader(BufReader::new(s))?; 85 | Ok(CreateMultipartUploadResponse(parsed)) 86 | } 87 | } 88 | 89 | impl CreateMultipartUploadResponse { 90 | #[must_use] 91 | pub fn upload_id(&self) -> &str { 92 | &self.0.upload_id 93 | } 94 | } 95 | 96 | impl<'a> S3Action<'a> for CreateMultipartUpload<'a> { 97 | const METHOD: Method = Method::Post; 98 | 99 | fn query_mut(&mut self) -> &mut Map<'a> { 100 | &mut self.query 101 | } 102 | 103 | fn headers_mut(&mut self) -> &mut Map<'a> { 104 | &mut self.headers 105 | } 106 | 107 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 108 | let url = self.bucket.object_url(self.object).unwrap(); 109 | let query = iter::once(("uploads", "1")); 110 | 111 | match self.credentials { 112 | Some(credentials) => sign( 113 | time, 114 | Self::METHOD, 115 | url, 116 | credentials.key(), 117 | credentials.secret(), 118 | credentials.token(), 119 | self.bucket.region(), 120 | expires_in.as_secs(), 121 | SortingIterator::new(query, self.query.iter()), 122 | self.headers.iter(), 123 | ), 124 | None => crate::signing::util::add_query_params(url, query), 125 | } 126 | } 127 | } 128 | 129 | #[cfg(test)] 130 | mod tests { 131 | use pretty_assertions::assert_eq; 132 | 133 | use super::*; 134 | use crate::{Bucket, Credentials, UrlStyle}; 135 | 136 | #[test] 137 | fn aws_example() { 138 | // Fri, 24 May 2013 00:00:00 GMT 139 | let date = Timestamp::from_second(1369353600).unwrap(); 140 | let expires_in = Duration::from_secs(86400); 141 | 142 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 143 | let bucket = Bucket::new( 144 | endpoint, 145 | UrlStyle::VirtualHost, 146 | "examplebucket", 147 | "us-east-1", 148 | ) 149 | .unwrap(); 150 | let credentials = Credentials::new( 151 | "AKIAIOSFODNN7EXAMPLE", 152 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 153 | ); 154 | 155 | let action = CreateMultipartUpload::new(&bucket, Some(&credentials), "test.txt"); 156 | 157 | let url = action.sign_with_time(expires_in, &date); 158 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&uploads=1&X-Amz-Signature=a6289f9e5ff2a914c6e324403bcd00b1d258c568487faa50d317ef0910c25c0a"; 159 | 160 | assert_eq!(expected, url.as_str()); 161 | } 162 | 163 | #[test] 164 | fn anonymous_custom_query() { 165 | let expires_in = Duration::from_secs(86400); 166 | 167 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 168 | let bucket = Bucket::new( 169 | endpoint, 170 | UrlStyle::VirtualHost, 171 | "examplebucket", 172 | "us-east-1", 173 | ) 174 | .unwrap(); 175 | 176 | let action = CreateMultipartUpload::new(&bucket, None, "test.txt"); 177 | let url = action.sign(expires_in); 178 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?uploads=1"; 179 | 180 | assert_eq!(expected, url.as_str()); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/actions/multipart_upload/list_parts.rs: -------------------------------------------------------------------------------- 1 | use std::io::BufRead; 2 | use std::iter; 3 | use std::time::Duration; 4 | 5 | use jiff::Timestamp; 6 | use serde::Deserialize; 7 | use url::Url; 8 | 9 | use crate::actions::Method; 10 | use crate::actions::S3Action; 11 | use crate::signing::sign; 12 | use crate::sorting_iter::SortingIterator; 13 | use crate::{Bucket, Credentials, Map}; 14 | 15 | /// Lists the parts that have been uploaded for a specific multipart upload. 16 | /// 17 | /// If `next_part_number_marker` is `Some` the response is truncated, and the 18 | /// rest of the list can be retrieved by reusing the `ListParts` action 19 | /// but with `part_number_marker` set to the value of `next_part_number_marker` 20 | /// received in the previous response. 21 | /// 22 | /// Find out more about `ListParts` from the [AWS API Reference][api] 23 | /// 24 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html 25 | #[derive(Debug, Clone)] 26 | pub struct ListParts<'a> { 27 | bucket: &'a Bucket, 28 | credentials: Option<&'a Credentials>, 29 | object: &'a str, 30 | upload_id: &'a str, 31 | 32 | query: Map<'a>, 33 | headers: Map<'a>, 34 | } 35 | 36 | #[allow(clippy::module_name_repetitions)] 37 | #[derive(Debug, Clone, Deserialize)] 38 | pub struct ListPartsResponse { 39 | #[serde(rename = "Part")] 40 | #[serde(default)] 41 | pub parts: Vec, 42 | #[serde(rename = "MaxParts")] 43 | pub max_parts: u16, 44 | #[serde(rename = "IsTruncated")] 45 | is_truncated: bool, 46 | #[serde(rename = "NextPartNumberMarker")] 47 | pub next_part_number_marker: Option, 48 | } 49 | 50 | #[derive(Debug, Clone, Deserialize)] 51 | pub struct PartsContent { 52 | #[serde(rename = "PartNumber")] 53 | pub number: u16, 54 | #[serde(rename = "ETag")] 55 | pub etag: String, 56 | #[serde(rename = "LastModified")] 57 | pub last_modified: String, 58 | #[serde(rename = "Size")] 59 | pub size: u64, 60 | } 61 | 62 | impl<'a> ListParts<'a> { 63 | #[must_use] 64 | pub const fn new( 65 | bucket: &'a Bucket, 66 | credentials: Option<&'a Credentials>, 67 | object: &'a str, 68 | upload_id: &'a str, 69 | ) -> Self { 70 | Self { 71 | bucket, 72 | credentials, 73 | object, 74 | upload_id, 75 | 76 | query: Map::new(), 77 | headers: Map::new(), 78 | } 79 | } 80 | 81 | pub fn set_max_parts(&mut self, max_parts: u16) { 82 | self.query.insert("max-parts", max_parts.to_string()); 83 | } 84 | 85 | pub fn set_part_number_marker(&mut self, part_number_marker: u16) { 86 | self.query 87 | .insert("part-number-marker", part_number_marker.to_string()); 88 | } 89 | 90 | /// Parse the XML response from S3 into a struct 91 | /// 92 | /// # Errors 93 | /// 94 | /// Will return an error if the XML cannot be deserialized 95 | pub fn parse_response(s: impl AsRef<[u8]>) -> Result { 96 | Self::parse_response_from_reader(&mut s.as_ref()) 97 | } 98 | 99 | /// Parse the XML response from S3 into a struct 100 | /// 101 | /// # Errors 102 | /// 103 | /// Will return an error if the XML cannot be deserialized 104 | pub fn parse_response_from_reader( 105 | s: impl BufRead, 106 | ) -> Result { 107 | let mut parts: ListPartsResponse = quick_xml::de::from_reader(s)?; 108 | if !parts.is_truncated { 109 | parts.next_part_number_marker = None; 110 | } 111 | Ok(parts) 112 | } 113 | } 114 | 115 | impl<'a> S3Action<'a> for ListParts<'a> { 116 | const METHOD: Method = Method::Get; 117 | 118 | fn query_mut(&mut self) -> &mut Map<'a> { 119 | &mut self.query 120 | } 121 | 122 | fn headers_mut(&mut self) -> &mut Map<'a> { 123 | &mut self.headers 124 | } 125 | 126 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 127 | let url = self.bucket.object_url(self.object).unwrap(); 128 | let query = 129 | SortingIterator::new(iter::once(("uploadId", self.upload_id)), self.query.iter()); 130 | 131 | match self.credentials { 132 | Some(credentials) => sign( 133 | time, 134 | Self::METHOD, 135 | url, 136 | credentials.key(), 137 | credentials.secret(), 138 | credentials.token(), 139 | self.bucket.region(), 140 | expires_in.as_secs(), 141 | query, 142 | self.headers.iter(), 143 | ), 144 | None => crate::signing::util::add_query_params(url, query), 145 | } 146 | } 147 | } 148 | 149 | #[cfg(test)] 150 | mod tests { 151 | use pretty_assertions::assert_eq; 152 | 153 | use crate::{Bucket, Credentials, UrlStyle}; 154 | 155 | use super::*; 156 | 157 | #[test] 158 | fn aws_example() { 159 | // Fri, 24 May 2013 00:00:00 GMT 160 | let date = Timestamp::from_second(1369353600).unwrap(); 161 | let expires_in = Duration::from_secs(86400); 162 | 163 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 164 | let bucket = Bucket::new( 165 | endpoint, 166 | UrlStyle::VirtualHost, 167 | "examplebucket", 168 | "us-east-1", 169 | ) 170 | .unwrap(); 171 | let credentials = Credentials::new( 172 | "AKIAIOSFODNN7EXAMPLE", 173 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 174 | ); 175 | 176 | let mut action = ListParts::new(&bucket, Some(&credentials), "test.txt", "abcd"); 177 | action.set_max_parts(100); 178 | let url = action.sign_with_time(expires_in, &date); 179 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&max-parts=100&uploadId=abcd&X-Amz-Signature=10a814258808a79054a80e2aff66e95faba686648eb50bd143fe7fe7d6d7b6ce"; 180 | assert_eq!(expected, url.as_str()); 181 | 182 | let mut action = ListParts::new(&bucket, Some(&credentials), "test.txt", "abcd"); 183 | action.set_max_parts(50); 184 | action.set_part_number_marker(100); 185 | let url = action.sign_with_time(expires_in, &date); 186 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&max-parts=50&part-number-marker=100&uploadId=abcd&X-Amz-Signature=ea8eecb4f2534d606474497e6088ceb262081bf7c5a289ff0598aafdd66055da"; 187 | assert_eq!(expected, url.as_str()); 188 | } 189 | 190 | #[test] 191 | fn anonymous_custom_query() { 192 | let expires_in = Duration::from_secs(86400); 193 | 194 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 195 | let bucket = Bucket::new( 196 | endpoint, 197 | UrlStyle::VirtualHost, 198 | "examplebucket", 199 | "us-east-1", 200 | ) 201 | .unwrap(); 202 | 203 | let mut action = ListParts::new(&bucket, None, "test.txt", "abcd"); 204 | action.set_max_parts(100); 205 | let url = action.sign(expires_in); 206 | let expected = 207 | "https://examplebucket.s3.amazonaws.com/test.txt?max-parts=100&uploadId=abcd"; 208 | assert_eq!(expected, url.as_str()); 209 | 210 | let mut action = ListParts::new(&bucket, None, "test.txt", "abcd"); 211 | action.set_max_parts(50); 212 | action.set_part_number_marker(100); 213 | let url = action.sign(expires_in); 214 | let expected = 215 | "https://examplebucket.s3.amazonaws.com/test.txt?max-parts=50&part-number-marker=100&uploadId=abcd"; 216 | assert_eq!(expected, url.as_str()); 217 | } 218 | 219 | #[test] 220 | fn parse() { 221 | let input = r#" 222 | 223 | 224 | example-bucket 225 | example-object 226 | XXBsb2FkIElEIGZvciBlbHZpbmcncyVcdS1tb3ZpZS5tMnRzEEEwbG9hZA 227 | 228 | arn:aws:iam::111122223333:user/some-user-11116a31-17b5-4fb7-9df5-b288870f11xx 229 | umat-user-11116a31-17b5-4fb7-9df5-b288870f11xx 230 | 231 | 232 | 75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a 233 | someName 234 | 235 | STANDARD 236 | 1 237 | 3 238 | 2 239 | true 240 | 241 | 2 242 | 2010-11-10T20:48:34.000Z 243 | "7778aef83f66abc1fa1e8477f296d394" 244 | 10485760 245 | 246 | 247 | 3 248 | 2010-11-10T20:48:33.000Z 249 | "aaaa18db4cc2f85cedef654fccc4a4x8" 250 | 10485760 251 | 252 | 253 | "#; 254 | 255 | let parsed = ListParts::parse_response(input).unwrap(); 256 | assert_eq!(parsed.parts.len(), 2); 257 | 258 | let part_1 = &parsed.parts[0]; 259 | assert_eq!(part_1.etag, "\"7778aef83f66abc1fa1e8477f296d394\""); 260 | assert_eq!(part_1.number, 2); 261 | assert_eq!(part_1.last_modified, "2010-11-10T20:48:34.000Z"); 262 | assert_eq!(part_1.size, 10485760); 263 | 264 | let part_2 = &parsed.parts[1]; 265 | assert_eq!(part_2.etag, "\"aaaa18db4cc2f85cedef654fccc4a4x8\""); 266 | assert_eq!(part_2.number, 3); 267 | assert_eq!(part_2.last_modified, "2010-11-10T20:48:33.000Z"); 268 | assert_eq!(part_2.size, 10485760); 269 | 270 | assert_eq!(parsed.max_parts, 2); 271 | assert_eq!(parsed.next_part_number_marker, Some(3)); 272 | } 273 | 274 | #[test] 275 | fn parse_no_parts() { 276 | let input = r#" 277 | 278 | 279 | example-bucket 280 | example-object 281 | XXBsb2FkIElEIGZvciBlbHZpbmcncyVcdS1tb3ZpZS5tMnRzEEEwbG9hZA 282 | 283 | arn:aws:iam::111122223333:user/some-user-11116a31-17b5-4fb7-9df5-b288870f11xx 284 | umat-user-11116a31-17b5-4fb7-9df5-b288870f11xx 285 | 286 | 287 | 75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a 288 | someName 289 | 290 | STANDARD 291 | 1 292 | 2 293 | false 294 | 295 | "#; 296 | 297 | let parsed = ListParts::parse_response(input).unwrap(); 298 | assert!(parsed.parts.is_empty()); 299 | assert_eq!(parsed.max_parts, 2); 300 | assert!(parsed.next_part_number_marker.is_none()); 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/actions/multipart_upload/mod.rs: -------------------------------------------------------------------------------- 1 | pub(super) mod abort; 2 | #[cfg(feature = "full")] 3 | pub(super) mod complete; 4 | #[cfg(feature = "full")] 5 | pub(super) mod create; 6 | #[cfg(feature = "full")] 7 | pub(super) mod list_parts; 8 | pub(super) mod upload; 9 | -------------------------------------------------------------------------------- /src/actions/multipart_upload/upload.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use crate::actions::Method; 7 | use crate::actions::S3Action; 8 | use crate::signing::sign; 9 | use crate::sorting_iter::SortingIterator; 10 | use crate::{Bucket, Credentials, Map}; 11 | 12 | /// Upload a part to a previously created multipart upload. 13 | /// 14 | /// Every part must be between 5 MB and 5 GB in size, except for the last part. 15 | /// 16 | /// The part must be uploaded via a PUT request, on success the server will 17 | /// return an `ETag` header which must be given to 18 | /// [`CompleteMultipartUpload`][crate::actions::CompleteMultipartUpload] in order to 19 | /// complete the upload. 20 | /// 21 | /// A maximum of 10,000 parts can be uploaded to a single multipart upload. 22 | /// 23 | /// The uploaded part will consume storage on S3 until the multipart upload 24 | /// is completed or aborted. 25 | /// 26 | /// Find out more about `UploadPart` from the [AWS API Reference][api] 27 | /// 28 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html 29 | #[allow(clippy::module_name_repetitions)] 30 | #[derive(Debug, Clone)] 31 | pub struct UploadPart<'a> { 32 | bucket: &'a Bucket, 33 | credentials: Option<&'a Credentials>, 34 | object: &'a str, 35 | 36 | part_number: u16, 37 | upload_id: &'a str, 38 | 39 | query: Map<'a>, 40 | headers: Map<'a>, 41 | } 42 | 43 | impl<'a> UploadPart<'a> { 44 | #[inline] 45 | #[must_use] 46 | pub const fn new( 47 | bucket: &'a Bucket, 48 | credentials: Option<&'a Credentials>, 49 | object: &'a str, 50 | part_number: u16, 51 | upload_id: &'a str, 52 | ) -> Self { 53 | Self { 54 | bucket, 55 | credentials, 56 | object, 57 | 58 | part_number, 59 | upload_id, 60 | 61 | query: Map::new(), 62 | headers: Map::new(), 63 | } 64 | } 65 | } 66 | 67 | impl<'a> S3Action<'a> for UploadPart<'a> { 68 | const METHOD: Method = Method::Put; 69 | 70 | fn query_mut(&mut self) -> &mut Map<'a> { 71 | &mut self.query 72 | } 73 | 74 | fn headers_mut(&mut self) -> &mut Map<'a> { 75 | &mut self.headers 76 | } 77 | 78 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 79 | let url = self.bucket.object_url(self.object).unwrap(); 80 | 81 | let part_number = self.part_number.to_string(); 82 | let query = [ 83 | ("partNumber", part_number.as_str()), 84 | ("uploadId", self.upload_id), 85 | ]; 86 | 87 | match self.credentials { 88 | Some(credentials) => sign( 89 | time, 90 | Self::METHOD, 91 | url, 92 | credentials.key(), 93 | credentials.secret(), 94 | credentials.token(), 95 | self.bucket.region(), 96 | expires_in.as_secs(), 97 | SortingIterator::new(query.iter().copied(), self.query.iter()), 98 | self.headers.iter(), 99 | ), 100 | None => crate::signing::util::add_query_params(url, query.iter().copied()), 101 | } 102 | } 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | use pretty_assertions::assert_eq; 108 | 109 | use super::*; 110 | use crate::{Bucket, Credentials, UrlStyle}; 111 | 112 | #[test] 113 | fn aws_example() { 114 | // Fri, 24 May 2013 00:00:00 GMT 115 | let date = Timestamp::from_second(1369353600).unwrap(); 116 | 117 | let expires_in = Duration::from_secs(86400); 118 | 119 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 120 | let bucket = Bucket::new( 121 | endpoint, 122 | UrlStyle::VirtualHost, 123 | "examplebucket", 124 | "us-east-1", 125 | ) 126 | .unwrap(); 127 | let credentials = Credentials::new( 128 | "AKIAIOSFODNN7EXAMPLE", 129 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 130 | ); 131 | 132 | let action = UploadPart::new(&bucket, Some(&credentials), "test.txt", 1, "abcd"); 133 | 134 | let url = action.sign_with_time(expires_in, &date); 135 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&partNumber=1&uploadId=abcd&X-Amz-Signature=d2ed12e1e116c88a79cd6d1726f5fe75c99db8a0292ba000f97ecc309a9303f8"; 136 | 137 | assert_eq!(expected, url.as_str()); 138 | } 139 | 140 | #[test] 141 | fn anonymous_custom_query() { 142 | let expires_in = Duration::from_secs(86400); 143 | 144 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 145 | let bucket = Bucket::new( 146 | endpoint, 147 | UrlStyle::VirtualHost, 148 | "examplebucket", 149 | "us-east-1", 150 | ) 151 | .unwrap(); 152 | 153 | let action = UploadPart::new(&bucket, None, "test.txt", 1, "abcd"); 154 | let url = action.sign(expires_in); 155 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?partNumber=1&uploadId=abcd"; 156 | 157 | assert_eq!(expected, url.as_str()); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/actions/put_object.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use super::S3Action; 7 | use crate::actions::Method; 8 | use crate::signing::sign; 9 | use crate::{Bucket, Credentials, Map}; 10 | 11 | /// Upload a file to S3, using a `PUT` request. 12 | /// 13 | /// Find out more about `PutObject` from the [AWS API Reference][api] 14 | /// 15 | /// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html 16 | #[derive(Debug, Clone)] 17 | pub struct PutObject<'a> { 18 | bucket: &'a Bucket, 19 | credentials: Option<&'a Credentials>, 20 | object: &'a str, 21 | 22 | query: Map<'a>, 23 | headers: Map<'a>, 24 | } 25 | 26 | impl<'a> PutObject<'a> { 27 | #[inline] 28 | #[must_use] 29 | pub const fn new( 30 | bucket: &'a Bucket, 31 | credentials: Option<&'a Credentials>, 32 | object: &'a str, 33 | ) -> Self { 34 | Self { 35 | bucket, 36 | credentials, 37 | object, 38 | 39 | query: Map::new(), 40 | headers: Map::new(), 41 | } 42 | } 43 | } 44 | 45 | impl<'a> S3Action<'a> for PutObject<'a> { 46 | const METHOD: Method = Method::Put; 47 | 48 | fn query_mut(&mut self) -> &mut Map<'a> { 49 | &mut self.query 50 | } 51 | 52 | fn headers_mut(&mut self) -> &mut Map<'a> { 53 | &mut self.headers 54 | } 55 | 56 | fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url { 57 | let url = self.bucket.object_url(self.object).unwrap(); 58 | 59 | match self.credentials { 60 | Some(credentials) => sign( 61 | time, 62 | Self::METHOD, 63 | url, 64 | credentials.key(), 65 | credentials.secret(), 66 | credentials.token(), 67 | self.bucket.region(), 68 | expires_in.as_secs(), 69 | self.query.iter(), 70 | self.headers.iter(), 71 | ), 72 | None => url, 73 | } 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use pretty_assertions::assert_eq; 80 | 81 | use super::*; 82 | use crate::{Bucket, Credentials, UrlStyle}; 83 | 84 | #[test] 85 | fn aws_example() { 86 | // Fri, 24 May 2013 00:00:00 GMT 87 | let date = Timestamp::from_second(1369353600).unwrap(); 88 | let expires_in = Duration::from_secs(86400); 89 | 90 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 91 | let bucket = Bucket::new( 92 | endpoint, 93 | UrlStyle::VirtualHost, 94 | "examplebucket", 95 | "us-east-1", 96 | ) 97 | .unwrap(); 98 | let credentials = Credentials::new( 99 | "AKIAIOSFODNN7EXAMPLE", 100 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 101 | ); 102 | 103 | let action = PutObject::new(&bucket, Some(&credentials), "test.txt"); 104 | 105 | let url = action.sign_with_time(expires_in, &date); 106 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=f4db56459304dafaa603a99a23c6bea8821890259a65c18ff503a4a72a80efd9"; 107 | 108 | assert_eq!(expected, url.as_str()); 109 | } 110 | 111 | #[test] 112 | fn anonymous_custom_query() { 113 | let expires_in = Duration::from_secs(86400); 114 | 115 | let endpoint = "https://s3.amazonaws.com".parse().unwrap(); 116 | let bucket = Bucket::new( 117 | endpoint, 118 | UrlStyle::VirtualHost, 119 | "examplebucket", 120 | "us-east-1", 121 | ) 122 | .unwrap(); 123 | 124 | let action = PutObject::new(&bucket, None, "test.txt"); 125 | let url = action.sign(expires_in); 126 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt"; 127 | 128 | assert_eq!(expected, url.as_str()); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/base64.rs: -------------------------------------------------------------------------------- 1 | use ::base64::engine::general_purpose::STANDARD; 2 | use ::base64::engine::Engine as _; 3 | 4 | pub(crate) fn encode>(input: T) -> String { 5 | STANDARD.encode(input) 6 | } 7 | -------------------------------------------------------------------------------- /src/bucket.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::error::Error as StdError; 3 | use std::fmt::{self, Display}; 4 | 5 | use url::{ParseError, Url}; 6 | 7 | use crate::actions::{ 8 | AbortMultipartUpload, CreateBucket, DeleteBucket, DeleteObject, GetObject, HeadBucket, 9 | HeadObject, PutObject, UploadPart, 10 | }; 11 | #[cfg(feature = "full")] 12 | use crate::actions::{ 13 | CompleteMultipartUpload, CreateMultipartUpload, DeleteObjects, ListObjectsV2, ListParts, 14 | }; 15 | use crate::signing::util::percent_encode_path; 16 | use crate::Credentials; 17 | 18 | /// An S3 bucket 19 | /// 20 | /// ## Path style url 21 | /// 22 | /// ```rust 23 | /// # use rusty_s3::{Bucket, UrlStyle}; 24 | /// let endpoint = "https://s3.dualstack.eu-west-1.amazonaws.com".parse().expect("endpoint is a valid Url"); 25 | /// let path_style = UrlStyle::Path; 26 | /// let name = "rusty-s3"; 27 | /// let region = "eu-west-1"; 28 | /// 29 | /// let bucket = Bucket::new(endpoint, path_style, name, region).expect("Url has a valid scheme and host"); 30 | /// assert_eq!(bucket.base_url().as_str(), "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/"); 31 | /// assert_eq!(bucket.name(), "rusty-s3"); 32 | /// assert_eq!(bucket.region(), "eu-west-1"); 33 | /// assert_eq!(bucket.object_url("duck.jpg").expect("url is valid").as_str(), "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/duck.jpg"); 34 | /// ``` 35 | /// 36 | /// ## Domain style url 37 | /// 38 | /// ```rust 39 | /// # use rusty_s3::{Bucket, UrlStyle}; 40 | /// let endpoint = "https://s3.dualstack.eu-west-1.amazonaws.com".parse().expect("endpoint is a valid Url"); 41 | /// let path_style = UrlStyle::VirtualHost; 42 | /// let name = "rusty-s3"; 43 | /// let region = "eu-west-1"; 44 | /// 45 | /// let bucket = Bucket::new(endpoint, path_style, name, region).expect("Url has a valid scheme and host"); 46 | /// assert_eq!(bucket.base_url().as_str(), "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com/"); 47 | /// assert_eq!(bucket.name(), "rusty-s3"); 48 | /// assert_eq!(bucket.region(), "eu-west-1"); 49 | /// assert_eq!(bucket.object_url("duck.jpg").expect("url is valid").as_str(), "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com/duck.jpg"); 50 | /// ``` 51 | #[derive(Debug, Clone, PartialEq, Eq)] 52 | pub struct Bucket { 53 | base_url: Url, 54 | name: Cow<'static, str>, 55 | region: Cow<'static, str>, 56 | } 57 | 58 | /// The request url format of a S3 bucket. 59 | #[derive(Debug, Clone, Copy)] 60 | pub enum UrlStyle { 61 | /// Requests will use "path-style" url: i.e: 62 | /// `https://s3..amazonaws.com//`. 63 | /// 64 | /// This style should be considered deprecated and is **NOT RECOMMENDED**. 65 | /// Check [Amazon S3 Path Deprecation Plan](https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/) 66 | /// for more informations. 67 | Path, 68 | /// Requests will use "virtual-hosted-style" urls, i.e: 69 | /// `https://.s3..amazonaws.com/`. 70 | VirtualHost, 71 | } 72 | 73 | #[allow(clippy::module_name_repetitions)] 74 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 75 | pub enum BucketError { 76 | UnsupportedScheme, 77 | MissingHost, 78 | ParseError(ParseError), 79 | } 80 | 81 | impl From for BucketError { 82 | fn from(error: ParseError) -> Self { 83 | Self::ParseError(error) 84 | } 85 | } 86 | 87 | impl Bucket { 88 | /// Construct a new S3 bucket 89 | /// 90 | /// # Errors 91 | /// 92 | /// Returns a `BucketError` if the `endpoint` is not a valid url, or if the `endpoint` is missing the host. 93 | pub fn new( 94 | endpoint: Url, 95 | path_style: UrlStyle, 96 | name: impl Into>, 97 | region: impl Into>, 98 | ) -> Result { 99 | endpoint.host_str().ok_or(BucketError::MissingHost)?; 100 | 101 | match endpoint.scheme() { 102 | "http" | "https" => {} 103 | _ => return Err(BucketError::UnsupportedScheme), 104 | }; 105 | 106 | let name = name.into(); 107 | let region = region.into(); 108 | 109 | let base_url = base_url(endpoint, &name, path_style)?; 110 | 111 | Ok(Self { 112 | base_url, 113 | name, 114 | region, 115 | }) 116 | } 117 | 118 | /// Get the base url of this s3 `Bucket` 119 | #[must_use] 120 | pub const fn base_url(&self) -> &Url { 121 | &self.base_url 122 | } 123 | 124 | /// Get the name of this `Bucket` 125 | #[must_use] 126 | pub fn name(&self) -> &str { 127 | &self.name 128 | } 129 | 130 | /// Get the region of this `Bucket` 131 | #[must_use] 132 | pub fn region(&self) -> &str { 133 | &self.region 134 | } 135 | 136 | /// Generate an url to an object of this `Bucket` 137 | /// 138 | /// This is not a signed url, it's just the starting point for 139 | /// generating an url to an S3 object. 140 | /// 141 | /// # Errors 142 | /// 143 | /// Returns a `ParseError` if the object is not a valid path. 144 | pub fn object_url(&self, object: &str) -> Result { 145 | let object: Cow<'_, str> = percent_encode_path(object).into(); 146 | self.base_url.join(&object) 147 | } 148 | } 149 | 150 | fn base_url(mut endpoint: Url, name: &str, path_style: UrlStyle) -> Result { 151 | match path_style { 152 | UrlStyle::Path => { 153 | let path = format!("{name}/"); 154 | endpoint.join(&path) 155 | } 156 | UrlStyle::VirtualHost => { 157 | let host = format!("{}.{}", name, endpoint.host_str().unwrap()); 158 | endpoint.set_host(Some(&host))?; 159 | Ok(endpoint) 160 | } 161 | } 162 | } 163 | 164 | // === Bucket level actions === 165 | 166 | impl Bucket { 167 | /// Create a new bucket. 168 | /// 169 | /// See [`CreateBucket`] for more details. 170 | #[must_use] 171 | pub const fn create_bucket<'a>(&'a self, credentials: &'a Credentials) -> CreateBucket<'a> { 172 | CreateBucket::new(self, credentials) 173 | } 174 | 175 | /// Delete a bucket. 176 | /// 177 | /// See [`DeleteBucket`] for more details. 178 | #[must_use] 179 | pub const fn delete_bucket<'a>(&'a self, credentials: &'a Credentials) -> DeleteBucket<'a> { 180 | DeleteBucket::new(self, credentials) 181 | } 182 | } 183 | 184 | // === Basic actions === 185 | 186 | impl Bucket { 187 | /// Retrieve an object's metadata from S3, using a `HEAD` request. 188 | /// 189 | /// See [`HeadObject`] for more details. 190 | #[must_use] 191 | pub const fn head_object<'a>( 192 | &'a self, 193 | credentials: Option<&'a Credentials>, 194 | object: &'a str, 195 | ) -> HeadObject<'a> { 196 | HeadObject::new(self, credentials, object) 197 | } 198 | 199 | /// Retrieve an bucket's metadata from S3, using a `HEAD` request. 200 | /// 201 | /// See [`HeadBucket`] for more details. 202 | #[must_use] 203 | pub const fn head_bucket<'a>(&'a self, credentials: Option<&'a Credentials>) -> HeadBucket<'a> { 204 | HeadBucket::new(self, credentials) 205 | } 206 | 207 | /// Retrieve an object from S3, using a `GET` request. 208 | /// 209 | /// See [`GetObject`] for more details. 210 | #[must_use] 211 | pub const fn get_object<'a>( 212 | &'a self, 213 | credentials: Option<&'a Credentials>, 214 | object: &'a str, 215 | ) -> GetObject<'a> { 216 | GetObject::new(self, credentials, object) 217 | } 218 | 219 | /// List all objects in the bucket. 220 | /// 221 | /// See [`ListObjectsV2`] for more details. 222 | #[cfg(feature = "full")] 223 | #[must_use] 224 | pub fn list_objects_v2<'a>( 225 | &'a self, 226 | credentials: Option<&'a Credentials>, 227 | ) -> ListObjectsV2<'a> { 228 | ListObjectsV2::new(self, credentials) 229 | } 230 | 231 | /// Upload a file to S3, using a `PUT` request. 232 | /// 233 | /// See [`PutObject`] for more details. 234 | #[must_use] 235 | pub const fn put_object<'a>( 236 | &'a self, 237 | credentials: Option<&'a Credentials>, 238 | object: &'a str, 239 | ) -> PutObject<'a> { 240 | PutObject::new(self, credentials, object) 241 | } 242 | 243 | /// Delete an object from S3, using a `DELETE` request. 244 | /// 245 | /// See [`DeleteObject`] for more details. 246 | #[must_use] 247 | pub const fn delete_object<'a>( 248 | &'a self, 249 | credentials: Option<&'a Credentials>, 250 | object: &'a str, 251 | ) -> DeleteObject<'a> { 252 | DeleteObject::new(self, credentials, object) 253 | } 254 | 255 | /// Delete multiple objects from S3 using a single `POST` request. 256 | /// 257 | /// See [`DeleteObjects`] for more details. 258 | #[cfg(feature = "full")] 259 | pub const fn delete_objects<'a, I>( 260 | &'a self, 261 | credentials: Option<&'a Credentials>, 262 | objects: I, 263 | ) -> DeleteObjects<'a, I> { 264 | DeleteObjects::new(self, credentials, objects) 265 | } 266 | } 267 | 268 | // === Multipart Upload === 269 | 270 | impl Bucket { 271 | /// Create a multipart upload. 272 | /// 273 | /// See [`CreateMultipartUpload`] for more details. 274 | #[cfg(feature = "full")] 275 | #[must_use] 276 | pub const fn create_multipart_upload<'a>( 277 | &'a self, 278 | credentials: Option<&'a Credentials>, 279 | object: &'a str, 280 | ) -> CreateMultipartUpload<'a> { 281 | CreateMultipartUpload::new(self, credentials, object) 282 | } 283 | 284 | /// Upload a part to a previously created multipart upload. 285 | /// 286 | /// See [`UploadPart`] for more details. 287 | #[must_use] 288 | pub const fn upload_part<'a>( 289 | &'a self, 290 | credentials: Option<&'a Credentials>, 291 | object: &'a str, 292 | part_number: u16, 293 | upload_id: &'a str, 294 | ) -> UploadPart<'a> { 295 | UploadPart::new(self, credentials, object, part_number, upload_id) 296 | } 297 | 298 | /// Complete a multipart upload. 299 | /// 300 | /// See [`CompleteMultipartUpload`] for more details. 301 | #[cfg(feature = "full")] 302 | pub const fn complete_multipart_upload<'a, I>( 303 | &'a self, 304 | credentials: Option<&'a Credentials>, 305 | object: &'a str, 306 | upload_id: &'a str, 307 | etags: I, 308 | ) -> CompleteMultipartUpload<'a, I> { 309 | CompleteMultipartUpload::new(self, credentials, object, upload_id, etags) 310 | } 311 | 312 | /// Abort multipart upload. 313 | /// 314 | /// See [`AbortMultipartUpload`] for more details. 315 | #[must_use] 316 | pub const fn abort_multipart_upload<'a>( 317 | &'a self, 318 | credentials: Option<&'a Credentials>, 319 | object: &'a str, 320 | upload_id: &'a str, 321 | ) -> AbortMultipartUpload<'a> { 322 | AbortMultipartUpload::new(self, credentials, object, upload_id) 323 | } 324 | 325 | /// Lists the parts that have been uploaded for a specific multipart upload. 326 | /// 327 | /// See [`ListParts`] for more details. 328 | #[cfg(feature = "full")] 329 | #[must_use] 330 | pub const fn list_parts<'a>( 331 | &'a self, 332 | credentials: Option<&'a Credentials>, 333 | object: &'a str, 334 | upload_id: &'a str, 335 | ) -> ListParts<'a> { 336 | ListParts::new(self, credentials, object, upload_id) 337 | } 338 | } 339 | 340 | impl Display for BucketError { 341 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 342 | match *self { 343 | Self::UnsupportedScheme => f.write_str("unsupported Url scheme"), 344 | Self::MissingHost => f.write_str("Url is missing the `host`"), 345 | Self::ParseError(e) => e.fmt(f), 346 | } 347 | } 348 | } 349 | 350 | impl StdError for BucketError {} 351 | 352 | #[cfg(test)] 353 | mod tests { 354 | use pretty_assertions::assert_eq; 355 | 356 | use super::*; 357 | #[cfg(feature = "full")] 358 | use crate::actions::ObjectIdentifier; 359 | 360 | #[test] 361 | fn new_pathstyle() { 362 | let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com" 363 | .parse() 364 | .unwrap(); 365 | let base_url: Url = "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/" 366 | .parse() 367 | .unwrap(); 368 | let name = "rusty-s3"; 369 | let region = "eu-west-1"; 370 | let bucket = Bucket::new(endpoint, UrlStyle::Path, name, region).unwrap(); 371 | 372 | assert_eq!(bucket.base_url(), &base_url); 373 | assert_eq!(bucket.name(), name); 374 | assert_eq!(bucket.region(), region); 375 | } 376 | 377 | #[test] 378 | fn new_domainstyle() { 379 | let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com" 380 | .parse() 381 | .unwrap(); 382 | let base_url: Url = "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com" 383 | .parse() 384 | .unwrap(); 385 | let name = "rusty-s3"; 386 | let region = "eu-west-1"; 387 | let bucket = Bucket::new(endpoint, UrlStyle::VirtualHost, name, region).unwrap(); 388 | 389 | assert_eq!(bucket.base_url(), &base_url); 390 | assert_eq!(bucket.name(), name); 391 | assert_eq!(bucket.region(), region); 392 | } 393 | 394 | #[test] 395 | fn new_bad_scheme() { 396 | let endpoint = "ftp://example.com/example".parse().unwrap(); 397 | let name = "rusty-s3"; 398 | let region = "eu-west-1"; 399 | assert_eq!( 400 | Bucket::new(endpoint, UrlStyle::Path, name, region), 401 | Err(BucketError::UnsupportedScheme) 402 | ); 403 | } 404 | 405 | #[test] 406 | fn new_missing_host() { 407 | let endpoint = "file:///home/something".parse().unwrap(); 408 | let name = "rusty-s3"; 409 | let region = "eu-west-1"; 410 | assert_eq!( 411 | Bucket::new(endpoint, UrlStyle::Path, name, region), 412 | Err(BucketError::MissingHost) 413 | ); 414 | } 415 | 416 | #[test] 417 | fn object_url_pathstyle() { 418 | let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com" 419 | .parse() 420 | .unwrap(); 421 | let name = "rusty-s3"; 422 | let region = "eu-west-1"; 423 | let bucket = Bucket::new(endpoint, UrlStyle::Path, name, region).unwrap(); 424 | 425 | let path_style = bucket.object_url("something/cat.jpg").unwrap(); 426 | assert_eq!( 427 | "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/something/cat.jpg", 428 | path_style.as_str() 429 | ); 430 | } 431 | 432 | #[test] 433 | fn object_url_domainstyle() { 434 | let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com" 435 | .parse() 436 | .unwrap(); 437 | let name = "rusty-s3"; 438 | let region = "eu-west-1"; 439 | let bucket = Bucket::new(endpoint, UrlStyle::VirtualHost, name, region).unwrap(); 440 | 441 | let domain_style = bucket.object_url("something/cat.jpg").unwrap(); 442 | assert_eq!( 443 | "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com/something/cat.jpg", 444 | domain_style.as_str() 445 | ); 446 | } 447 | 448 | #[test] 449 | fn all_actions() { 450 | let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com" 451 | .parse() 452 | .unwrap(); 453 | 454 | let name = "rusty-s3"; 455 | let region = "eu-west-1"; 456 | let bucket = Bucket::new(endpoint, UrlStyle::Path, name, region).unwrap(); 457 | 458 | let credentials = Credentials::new( 459 | "AKIAIOSFODNN7EXAMPLE", 460 | "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 461 | ); 462 | 463 | let _ = bucket.create_bucket(&credentials); 464 | let _ = bucket.delete_bucket(&credentials); 465 | 466 | let _ = bucket.head_object(Some(&credentials), "duck.jpg"); 467 | let _ = bucket.get_object(Some(&credentials), "duck.jpg"); 468 | #[cfg(feature = "full")] 469 | let _ = bucket.list_objects_v2(Some(&credentials)); 470 | let _ = bucket.put_object(Some(&credentials), "duck.jpg"); 471 | let _ = bucket.delete_object(Some(&credentials), "duck.jpg"); 472 | #[cfg(feature = "full")] 473 | let _ = bucket.delete_objects(Some(&credentials), std::iter::empty::()); 474 | 475 | #[cfg(feature = "full")] 476 | let _ = bucket.create_multipart_upload(Some(&credentials), "duck.jpg"); 477 | let _ = bucket.upload_part(Some(&credentials), "duck.jpg", 1, "abcd"); 478 | #[cfg(feature = "full")] 479 | let _ = bucket.complete_multipart_upload( 480 | Some(&credentials), 481 | "duck.jpg", 482 | "abcd", 483 | ["1234"].iter().copied(), 484 | ); 485 | let _ = bucket.abort_multipart_upload(Some(&credentials), "duck.jpg", "abcd"); 486 | #[cfg(feature = "full")] 487 | let _ = bucket.list_parts(Some(&credentials), "duck.jpg", "abcd"); 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /src/credentials/mod.rs: -------------------------------------------------------------------------------- 1 | //! Credentials management types 2 | //! 3 | //! [`RotatingCredentials`] wraps [`Credentials`] and gives the ability to 4 | //! rotate them at any point in the program, keeping all copies of the same 5 | //! [`RotatingCredentials`] in sync with the latest version. 6 | //! 7 | //! [`Ec2SecurityCredentialsMetadataResponse`] parses the response from the 8 | //! [EC2 metadata service](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html), 9 | //! which provides an endpoint for retrieving credentials using the permissions 10 | //! for the [attached IAM roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html). 11 | 12 | use std::env; 13 | use std::fmt::{self, Debug, Formatter}; 14 | 15 | #[allow(clippy::module_name_repetitions)] 16 | pub use self::rotating::RotatingCredentials; 17 | #[cfg(feature = "full")] 18 | pub use self::serde::Ec2SecurityCredentialsMetadataResponse; 19 | use zeroize::Zeroizing; 20 | 21 | mod rotating; 22 | #[cfg(feature = "full")] 23 | mod serde; 24 | 25 | /// S3 credentials 26 | #[derive(Clone, PartialEq, Eq)] 27 | pub struct Credentials { 28 | key: String, 29 | secret: Zeroizing, 30 | token: Option, 31 | } 32 | 33 | impl Credentials { 34 | /// Construct a new `Credentials` using the provided key and secret 35 | #[inline] 36 | pub fn new(key: impl Into, secret: impl Into) -> Self { 37 | Self::new_with_maybe_token(key.into(), secret.into(), None) 38 | } 39 | 40 | /// Construct a new `Credentials` using the provided key, secret and token 41 | #[inline] 42 | pub fn new_with_token( 43 | key: impl Into, 44 | secret: impl Into, 45 | token: impl Into, 46 | ) -> Self { 47 | Self::new_with_maybe_token(key.into(), secret.into(), Some(token.into())) 48 | } 49 | 50 | #[inline] 51 | pub(super) fn new_with_maybe_token(key: String, secret: String, token: Option) -> Self { 52 | Self { 53 | key, 54 | secret: Zeroizing::new(secret), 55 | token, 56 | } 57 | } 58 | 59 | /// Construct a new `Credentials` using AWS's default environment variables 60 | /// 61 | /// Reads the key from the `AWS_ACCESS_KEY_ID` environment variable and the secret 62 | /// from the `AWS_SECRET_ACCESS_KEY` environment variable. 63 | /// If `AWS_SESSION_TOKEN` is set a token is also read. 64 | /// Returns `None` if either environment variables aren't set or they aren't valid utf-8. 65 | #[must_use] 66 | pub fn from_env() -> Option { 67 | let key = env::var("AWS_ACCESS_KEY_ID").ok()?; 68 | let secret = env::var("AWS_SECRET_ACCESS_KEY").ok()?; 69 | let token = env::var("AWS_SESSION_TOKEN").ok(); 70 | Some(Self::new_with_maybe_token(key, secret, token)) 71 | } 72 | 73 | /// Get the key of this `Credentials` 74 | #[inline] 75 | #[must_use] 76 | pub fn key(&self) -> &str { 77 | &self.key 78 | } 79 | 80 | /// Get the secret of this `Credentials` 81 | #[inline] 82 | #[must_use] 83 | pub fn secret(&self) -> &str { 84 | &self.secret 85 | } 86 | 87 | /// Get the token of this `Credentials`, if present 88 | #[inline] 89 | #[must_use] 90 | pub fn token(&self) -> Option<&str> { 91 | self.token.as_deref() 92 | } 93 | } 94 | 95 | impl Debug for Credentials { 96 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 97 | f.debug_struct("Credentials") 98 | .field("key", &self.key) 99 | .finish_non_exhaustive() 100 | } 101 | } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use pretty_assertions::assert_eq; 106 | 107 | use super::*; 108 | 109 | #[test] 110 | fn key_secret() { 111 | let credentials = Credentials::new("abcd", "1234"); 112 | assert_eq!(credentials.key(), "abcd"); 113 | assert_eq!(credentials.secret(), "1234"); 114 | assert!(credentials.token().is_none()); 115 | } 116 | 117 | #[test] 118 | fn key_secret_token() { 119 | let credentials = Credentials::new_with_token("abcd", "1234", "xyz"); 120 | assert_eq!(credentials.key(), "abcd"); 121 | assert_eq!(credentials.secret(), "1234"); 122 | assert_eq!(credentials.token(), Some("xyz")); 123 | } 124 | 125 | #[test] 126 | fn debug() { 127 | let credentials = Credentials::new("abcd", "1234"); 128 | let debug_output = format!("{credentials:?}"); 129 | assert_eq!(debug_output, "Credentials { key: \"abcd\", .. }"); 130 | } 131 | 132 | #[test] 133 | fn debug_token() { 134 | let credentials = Credentials::new_with_token("abcd", "1234", "xyz"); 135 | let debug_output = format!("{credentials:?}"); 136 | assert_eq!(debug_output, "Credentials { key: \"abcd\", .. }"); 137 | } 138 | 139 | #[test] 140 | fn from_env() { 141 | env::set_var("AWS_ACCESS_KEY_ID", "key"); 142 | env::set_var("AWS_SECRET_ACCESS_KEY", "secret"); 143 | 144 | let credentials = Credentials::from_env().unwrap(); 145 | assert_eq!(credentials.key(), "key"); 146 | assert_eq!(credentials.secret(), "secret"); 147 | assert!(credentials.token().is_none()); 148 | 149 | env::remove_var("AWS_ACCESS_KEY_ID"); 150 | env::remove_var("AWS_SECRET_ACCESS_KEY"); 151 | 152 | assert!(Credentials::from_env().is_none()); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/credentials/rotating.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Debug, Formatter, Result as FmtResult}; 2 | use std::sync::{Arc, RwLock}; 3 | 4 | use super::Credentials; 5 | 6 | /// Credentials that can be rotated 7 | /// 8 | /// This struct can be cloned and shared around the rest of 9 | /// the application and will always yield the latest credentials, 10 | /// by calling [`RotatingCredentials::get`]. 11 | /// 12 | /// Credentials can be updated by calling [`RotatingCredentials::update`]. 13 | #[allow(clippy::module_name_repetitions)] 14 | pub struct RotatingCredentials { 15 | inner: Arc>>, 16 | } 17 | 18 | impl RotatingCredentials { 19 | /// Construct a new `RotatingCredentials` using the provided key, secret and token 20 | #[must_use] 21 | pub fn new(key: String, secret: String, token: Option) -> Self { 22 | let credentials = Credentials::new_with_maybe_token(key, secret, token); 23 | 24 | Self { 25 | inner: Arc::new(RwLock::new(Arc::new(credentials))), 26 | } 27 | } 28 | 29 | /// Get the latest credentials inside this `RotatingCredentials` 30 | /// 31 | /// # Panics 32 | /// 33 | /// If the lock is poisoned 34 | #[must_use] 35 | pub fn get(&self) -> Arc { 36 | let lock = self.inner.read().expect("can't be poisoned"); 37 | Arc::clone(&lock) 38 | } 39 | 40 | /// Update the credentials inside this `RotatingCredentials` 41 | /// 42 | /// # Panics 43 | /// 44 | /// If the lock is poisoned 45 | pub fn update(&self, key: String, secret: String, token: Option) { 46 | let credentials = Credentials::new_with_maybe_token(key, secret, token); 47 | 48 | let mut lock = self.inner.write().expect("can't be poisoned"); 49 | match Arc::get_mut(&mut lock) { 50 | Some(arc) => *arc = credentials, 51 | None => *lock = Arc::new(credentials), 52 | }; 53 | } 54 | } 55 | 56 | impl Debug for RotatingCredentials { 57 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 58 | let current = self.get(); 59 | Debug::fmt(&*current, f) 60 | } 61 | } 62 | 63 | impl Clone for RotatingCredentials { 64 | fn clone(&self) -> Self { 65 | Self { 66 | inner: Arc::clone(&self.inner), 67 | } 68 | } 69 | 70 | fn clone_from(&mut self, source: &Self) { 71 | self.inner = Arc::clone(&source.inner); 72 | } 73 | } 74 | 75 | impl PartialEq for RotatingCredentials { 76 | fn eq(&self, other: &Self) -> bool { 77 | let current1 = self.get(); 78 | let current2 = other.get(); 79 | *current1 == *current2 80 | } 81 | } 82 | 83 | #[cfg(test)] 84 | mod tests { 85 | use pretty_assertions::assert_eq; 86 | 87 | use super::*; 88 | 89 | #[test] 90 | fn rotate() { 91 | let credentials = 92 | RotatingCredentials::new("abcd".into(), "1234".into(), Some("xyz".into())); 93 | 94 | let current = credentials.get(); 95 | assert_eq!(current.key(), "abcd"); 96 | assert_eq!(current.secret(), "1234"); 97 | assert_eq!(current.token(), Some("xyz")); 98 | drop(current); 99 | 100 | credentials.update("1234".into(), "5678".into(), Some("9012".into())); 101 | 102 | let current = credentials.get(); 103 | assert_eq!(current.key(), "1234"); 104 | assert_eq!(current.secret(), "5678"); 105 | assert_eq!(current.token(), Some("9012")); 106 | drop(current); 107 | 108 | credentials.update("dcba".into(), "4321".into(), Some("yxz".into())); 109 | 110 | let current = credentials.get(); 111 | assert_eq!(current.key(), "dcba"); 112 | assert_eq!(current.secret(), "4321"); 113 | assert_eq!(current.token(), Some("yxz")); 114 | drop(current); 115 | } 116 | 117 | #[test] 118 | fn rotate_cloned() { 119 | let credentials = 120 | RotatingCredentials::new("abcd".into(), "1234".into(), Some("xyz".into())); 121 | 122 | let current = credentials.get(); 123 | assert_eq!(current.key(), "abcd"); 124 | assert_eq!(current.secret(), "1234"); 125 | assert_eq!(current.token(), Some("xyz")); 126 | drop(current); 127 | 128 | let credentials2 = credentials.clone(); 129 | 130 | credentials.update("1234".into(), "5678".into(), Some("9012".into())); 131 | 132 | let current = credentials2.get(); 133 | assert_eq!(current.key(), "1234"); 134 | assert_eq!(current.secret(), "5678"); 135 | assert_eq!(current.token(), Some("9012")); 136 | drop(current); 137 | 138 | assert_eq!(credentials, credentials2); 139 | 140 | credentials.update("dcba".into(), "4321".into(), Some("yxz".into())); 141 | 142 | let current = credentials.get(); 143 | assert_eq!(current.key(), "dcba"); 144 | assert_eq!(current.secret(), "4321"); 145 | assert_eq!(current.token(), Some("yxz")); 146 | drop(current); 147 | 148 | assert_eq!(credentials, credentials2); 149 | } 150 | 151 | #[test] 152 | fn debug() { 153 | let credentials = 154 | RotatingCredentials::new("abcd".into(), "1234".into(), Some("xyz".into())); 155 | let debug_output = format!("{credentials:?}"); 156 | assert_eq!(debug_output, "Credentials { key: \"abcd\", .. }"); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/credentials/serde.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Debug, Formatter}; 2 | use std::mem; 3 | 4 | use jiff::Timestamp; 5 | use serde::Deserialize; 6 | use zeroize::Zeroize as _; 7 | 8 | use super::{Credentials, RotatingCredentials}; 9 | 10 | /// Parser for responses received from the EC2 security-credentials metadata service. 11 | #[derive(Clone, Deserialize)] 12 | pub struct Ec2SecurityCredentialsMetadataResponse { 13 | #[serde(rename = "AccessKeyId")] 14 | key: String, 15 | #[serde(rename = "SecretAccessKey")] 16 | secret: String, 17 | #[serde(rename = "Token")] 18 | token: String, 19 | #[serde(rename = "Expiration")] 20 | expiration: Timestamp, 21 | } 22 | 23 | impl Ec2SecurityCredentialsMetadataResponse { 24 | /// Deserialize a JSON response received from the EC2 metadata service. 25 | /// 26 | /// Parses the credentials from a response received from 27 | /// `http://169.254.169.254/latest/meta-data/iam/security-credentials/{name-of-IAM-role}`. 28 | /// 29 | /// # Errors 30 | /// 31 | /// Returns an error if the JSON is invalid. 32 | pub fn deserialize(s: &str) -> Result { 33 | serde_json::from_str(s) 34 | } 35 | 36 | /// Get the key of this `Ec2SecurityCredentialsMetadataResponse` 37 | #[inline] 38 | #[must_use] 39 | pub fn key(&self) -> &str { 40 | &self.key 41 | } 42 | 43 | /// Get the secret of this `Ec2SecurityCredentialsMetadataResponse` 44 | #[inline] 45 | #[must_use] 46 | pub fn secret(&self) -> &str { 47 | &self.secret 48 | } 49 | 50 | /// Get the token of this `Ec2SecurityCredentialsMetadataResponse` 51 | #[inline] 52 | #[must_use] 53 | pub fn token(&self) -> &str { 54 | &self.token 55 | } 56 | 57 | /// Get the expiration of the credentials of this `Ec2SecurityCredentialsMetadataResponse` 58 | #[inline] 59 | #[must_use] 60 | pub const fn expiration(&self) -> Timestamp { 61 | self.expiration 62 | } 63 | 64 | /// Convert this `Ec2SecurityCredentialsMetadataResponse` into [`Credentials`] 65 | #[inline] 66 | #[must_use] 67 | pub fn into_credentials(mut self) -> Credentials { 68 | let key = mem::take(&mut self.key); 69 | let secret = mem::take(&mut self.secret); 70 | let token = mem::take(&mut self.token); 71 | Credentials::new_with_token(key, secret, token) 72 | } 73 | 74 | /// Update a [`RotatingCredentials`] with the credentials of this `Ec2SecurityCredentialsMetadataResponse` 75 | #[inline] 76 | pub fn rotate_credentials(mut self, rotating: &RotatingCredentials) { 77 | let key = mem::take(&mut self.key); 78 | let secret = mem::take(&mut self.secret); 79 | let token = mem::take(&mut self.token); 80 | rotating.update(key, secret, Some(token)); 81 | } 82 | } 83 | 84 | impl Debug for Ec2SecurityCredentialsMetadataResponse { 85 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 86 | f.debug_struct("Ec2SecurityCredentialsMetadataResponse") 87 | .field("key", &self.key) 88 | .finish_non_exhaustive() 89 | } 90 | } 91 | 92 | impl Drop for Ec2SecurityCredentialsMetadataResponse { 93 | fn drop(&mut self) { 94 | self.secret.zeroize(); 95 | } 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use pretty_assertions::assert_eq; 101 | 102 | use super::*; 103 | 104 | #[test] 105 | fn deserialize() { 106 | let json = r#"{ 107 | "Code" : "Success", 108 | "LastUpdated" : "2020-12-28T16:47:50Z", 109 | "Type" : "AWS-HMAC", 110 | "AccessKeyId" : "some_access_key", 111 | "SecretAccessKey" : "some_secret_key", 112 | "Token" : "some_token", 113 | "Expiration" : "2020-12-28T23:10:09Z" 114 | }"#; 115 | 116 | let deserialized = Ec2SecurityCredentialsMetadataResponse::deserialize(json).unwrap(); 117 | assert_eq!(deserialized.key(), "some_access_key"); 118 | assert_eq!(deserialized.secret(), "some_secret_key"); 119 | assert_eq!(deserialized.token(), "some_token"); 120 | // 2020-12-28T23:10:09Z 121 | assert_eq!( 122 | deserialized 123 | .expiration() 124 | .duration_since(Timestamp::UNIX_EPOCH) 125 | .as_secs(), 126 | 1609197009 127 | ); 128 | 129 | let debug_output = format!("{deserialized:?}"); 130 | assert_eq!( 131 | debug_output, 132 | "Ec2SecurityCredentialsMetadataResponse { key: \"some_access_key\", .. }" 133 | ); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A simple, pure Rust AWS S3 client following a Sans-IO approach 2 | //! 3 | //! The rusty-s3 crate provides a convenient API for signing, building 4 | //! and parsing AWS S3 requests and responses. 5 | //! It follows a Sans-IO approach, meaning that the library itself doesn't 6 | //! send any of the requests. It's the reposibility of the user to choose an 7 | //! HTTP client, be it synchronous or asynchronous, and use it to send the requests. 8 | //! 9 | //! ## Basic getting started example 10 | //! 11 | //! ```rust 12 | //! use std::env; 13 | //! use std::time::Duration; 14 | //! use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle}; 15 | //! # env::set_var("AWS_ACCESS_KEY_ID", "key"); 16 | //! # env::set_var("AWS_SECRET_ACCESS_KEY", "secret"); 17 | //! 18 | //! // setting up a bucket 19 | //! let endpoint = "https://s3.dualstack.eu-west-1.amazonaws.com".parse().expect("endpoint is a valid Url"); 20 | //! let path_style = UrlStyle::VirtualHost; 21 | //! let name = "rusty-s3"; 22 | //! let region = "eu-west-1"; 23 | //! let bucket = Bucket::new(endpoint, path_style, name, region).expect("Url has a valid scheme and host"); 24 | //! 25 | //! // setting up the credentials 26 | //! let key = env::var("AWS_ACCESS_KEY_ID").expect("AWS_ACCESS_KEY_ID is set and a valid String"); 27 | //! let secret = env::var("AWS_SECRET_ACCESS_KEY").expect("AWS_SECRET_ACCESS_KEY is set and a valid String"); 28 | //! let credentials = Credentials::new(key, secret); 29 | //! 30 | //! // signing a request 31 | //! let presigned_url_duration = Duration::from_secs(60 * 60); 32 | //! let action = bucket.get_object(Some(&credentials), "duck.jpg"); 33 | //! println!("GET {}", action.sign(presigned_url_duration)); 34 | //! ``` 35 | 36 | #![warn( 37 | clippy::all, 38 | clippy::correctness, 39 | clippy::style, 40 | clippy::pedantic, 41 | clippy::perf, 42 | clippy::complexity, 43 | clippy::nursery, 44 | clippy::cargo 45 | )] 46 | #![deny( 47 | missing_debug_implementations, 48 | missing_copy_implementations, 49 | rust_2018_idioms, 50 | rustdoc::broken_intra_doc_links 51 | )] 52 | #![forbid(unsafe_code)] 53 | 54 | pub use self::actions::S3Action; 55 | pub use self::bucket::{Bucket, BucketError, UrlStyle}; 56 | pub use self::credentials::Credentials; 57 | pub use self::map::Map; 58 | pub use self::method::Method; 59 | 60 | pub mod actions; 61 | #[cfg(feature = "full")] 62 | pub(crate) mod base64; 63 | mod bucket; 64 | pub mod credentials; 65 | mod map; 66 | mod method; 67 | pub mod signing; 68 | pub(crate) mod sorting_iter; 69 | mod time; 70 | -------------------------------------------------------------------------------- /src/map.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::fmt::{self, Debug}; 3 | 4 | /// A map used for holding query string paramenters or headers 5 | #[derive(Clone)] 6 | pub struct Map<'a> { 7 | inner: Vec<(Cow<'a, str>, Cow<'a, str>)>, 8 | } 9 | 10 | impl<'a> Map<'a> { 11 | /// Construct a new empty `Map` 12 | #[inline] 13 | #[must_use] 14 | pub const fn new() -> Self { 15 | Self { inner: Vec::new() } 16 | } 17 | 18 | /// Get the number of elements in this `Map` 19 | #[inline] 20 | #[must_use] 21 | pub fn len(&self) -> usize { 22 | self.inner.len() 23 | } 24 | 25 | /// Return `true` if this `Map` is empty 26 | #[inline] 27 | #[must_use] 28 | pub fn is_empty(&self) -> bool { 29 | self.inner.is_empty() 30 | } 31 | 32 | /// Get the value of an element of this `Map`, or `None` if it doesn't contain `key` 33 | #[must_use] 34 | pub fn get(&self, key: &str) -> Option<&str> { 35 | self.inner 36 | .binary_search_by(|a| a.0.as_ref().cmp(key)) 37 | .map_or(None, |i| self.inner.get(i).map(|kv| kv.1.as_ref())) 38 | } 39 | 40 | /// Insert a new element in this `Map` 41 | /// 42 | /// If the `key` is already present, the `value` overwrites the existing value: 43 | /// 44 | /// ``` 45 | /// let mut map = rusty_s3::Map::new(); 46 | /// map.insert("k", "a"); 47 | /// assert_eq!(map.get("k"), Some("a")); 48 | /// map.insert("k", "b"); 49 | /// assert_eq!(map.get("k"), Some("b")); 50 | /// ``` 51 | /// 52 | /// # Panics 53 | /// 54 | /// In case of out of bound inner index access 55 | pub fn insert(&mut self, key: K, value: V) 56 | where 57 | K: Into>, 58 | V: Into>, 59 | { 60 | let key = key.into(); 61 | let value = value.into(); 62 | 63 | let i = self.inner.binary_search_by(|a| a.0.cmp(&key)); 64 | match i { 65 | Ok(i) => { 66 | let old_value = self.inner.get_mut(i).expect("i can't be out of bounds"); 67 | *old_value = (key, value); 68 | } 69 | Err(i) => self.inner.insert(i, (key, value)), 70 | } 71 | } 72 | 73 | /// Insert a new element in this `Map` 74 | /// 75 | /// If the `key` is already present, the `value` is appended to the existing value: 76 | /// 77 | /// ``` 78 | /// let mut map = rusty_s3::Map::new(); 79 | /// map.append("k", "a"); 80 | /// assert_eq!(map.get("k"), Some("a")); 81 | /// map.append("k", "b"); 82 | /// assert_eq!(map.get("k"), Some("a, b")); 83 | /// ``` 84 | /// 85 | /// # Panics 86 | /// 87 | /// In case of out of bound inner index access 88 | pub fn append(&mut self, key: K, value: V) 89 | where 90 | K: Into>, 91 | V: Into>, 92 | { 93 | let key = key.into(); 94 | let value = value.into(); 95 | 96 | let i = self.inner.binary_search_by(|a| a.0.cmp(&key)); 97 | match i { 98 | Ok(i) => { 99 | let old_value = self.inner.get_mut(i).expect("i can't be out of bounds"); 100 | let new_value = Cow::Owned(format!("{}, {}", old_value.1, value)); 101 | *old_value = (key, new_value); 102 | } 103 | Err(i) => self.inner.insert(i, (key, value)), 104 | } 105 | } 106 | 107 | /// Remove an element from this `Map` and return it 108 | pub fn remove(&mut self, key: &str) -> Option<(Cow<'a, str>, Cow<'a, str>)> { 109 | match self.inner.binary_search_by(|a| a.0.as_ref().cmp(key)) { 110 | Ok(i) => Some(self.inner.remove(i)), 111 | Err(_) => None, 112 | } 113 | } 114 | 115 | /// Return an `Iterator` over this map 116 | /// 117 | /// The elements are always sorted in alphabetical order based on the key. 118 | pub fn iter(&self) -> impl Iterator + Clone { 119 | self.inner.iter().map(|t| (t.0.as_ref(), t.1.as_ref())) 120 | } 121 | } 122 | 123 | impl Debug for Map<'_> { 124 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 125 | f.debug_map().entries(self.iter()).finish() 126 | } 127 | } 128 | 129 | impl Default for Map<'_> { 130 | #[inline] 131 | fn default() -> Self { 132 | Self::new() 133 | } 134 | } 135 | 136 | #[cfg(test)] 137 | mod tests { 138 | use pretty_assertions::assert_eq; 139 | 140 | use super::*; 141 | 142 | #[test] 143 | fn map() { 144 | let mut map = Map::new(); 145 | { 146 | assert_eq!(map.len(), 0); 147 | assert!(map.is_empty()); 148 | assert!(map.get("nothing").is_none()); 149 | 150 | let mut iter = map.iter(); 151 | assert!(iter.next().is_none()); 152 | } 153 | 154 | { 155 | map.insert("content-type", "text/plain"); 156 | assert_eq!(map.len(), 1); 157 | assert!(!map.is_empty()); 158 | assert!(map.get("nothing").is_none()); 159 | assert_eq!(map.get("content-type"), Some("text/plain")); 160 | 161 | let iter = map.iter(); 162 | iter.eq(vec![("content-type", "text/plain")]); 163 | } 164 | 165 | { 166 | map.insert("cache-control", "public, max-age=86400"); 167 | assert_eq!(map.len(), 2); 168 | assert!(!map.is_empty()); 169 | assert!(map.get("nothing").is_none()); 170 | assert_eq!(map.get("content-type"), Some("text/plain")); 171 | assert_eq!(map.get("cache-control"), Some("public, max-age=86400")); 172 | 173 | let iter = map.iter(); 174 | iter.eq(vec![ 175 | ("cache-control", "public, max-age=86400"), 176 | ("content-type", "text/plain"), 177 | ]); 178 | } 179 | 180 | { 181 | map.insert("x-amz-storage-class", "standard"); 182 | assert_eq!(map.len(), 3); 183 | assert!(!map.is_empty()); 184 | assert!(map.get("nothing").is_none()); 185 | assert_eq!(map.get("content-type"), Some("text/plain")); 186 | assert_eq!(map.get("cache-control"), Some("public, max-age=86400")); 187 | assert_eq!(map.get("x-amz-storage-class"), Some("standard")); 188 | 189 | let iter = map.iter(); 190 | iter.eq(vec![ 191 | ("cache-control", "public, max-age=86400"), 192 | ("content-type", "text/plain"), 193 | ("x-amz-storage-class", "standard"), 194 | ]); 195 | } 196 | 197 | { 198 | map.remove("content-type"); 199 | assert_eq!(map.len(), 2); 200 | assert!(!map.is_empty()); 201 | assert!(map.get("nothing").is_none()); 202 | assert_eq!(map.get("cache-control"), Some("public, max-age=86400")); 203 | assert_eq!(map.get("x-amz-storage-class"), Some("standard")); 204 | 205 | let iter = map.iter(); 206 | iter.eq(vec![ 207 | ("cache-control", "public, max-age=86400"), 208 | ("x-amz-storage-class", "standard"), 209 | ]); 210 | } 211 | 212 | { 213 | map.remove("x-amz-look-at-how-many-headers-you-have"); 214 | assert_eq!(map.len(), 2); 215 | assert!(!map.is_empty()); 216 | assert!(map.get("nothing").is_none()); 217 | assert_eq!(map.get("cache-control"), Some("public, max-age=86400")); 218 | assert_eq!(map.get("x-amz-storage-class"), Some("standard")); 219 | 220 | let iter = map.iter(); 221 | iter.eq(vec![ 222 | ("cache-control", "public, max-age=86400"), 223 | ("x-amz-storage-class", "standard"), 224 | ]); 225 | } 226 | 227 | { 228 | map.append("cache-control", "immutable"); 229 | assert_eq!(map.len(), 2); 230 | assert!(!map.is_empty()); 231 | assert!(map.get("nothing").is_none()); 232 | assert_eq!( 233 | map.get("cache-control"), 234 | Some("public, max-age=86400, immutable") 235 | ); 236 | assert_eq!(map.get("x-amz-storage-class"), Some("standard")); 237 | 238 | let iter = map.iter(); 239 | iter.eq(vec![ 240 | ("cache-control", "public, max-age=86400, immutable"), 241 | ("x-amz-storage-class", "standard"), 242 | ]); 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/method.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | /// The HTTP request method for an [`S3Action`](crate::actions::S3Action). 4 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 5 | pub enum Method { 6 | Head, 7 | Get, 8 | Post, 9 | Put, 10 | Delete, 11 | } 12 | 13 | impl Method { 14 | /// Convert this `Method` into an uppercase string. 15 | /// 16 | /// ```rust 17 | /// # use rusty_s3::Method; 18 | /// assert_eq!(Method::Get.to_str(), "GET"); 19 | /// ``` 20 | #[inline] 21 | #[must_use] 22 | pub const fn to_str(self) -> &'static str { 23 | match self { 24 | Self::Head => "HEAD", 25 | Self::Get => "GET", 26 | Self::Post => "POST", 27 | Self::Put => "PUT", 28 | Self::Delete => "DELETE", 29 | } 30 | } 31 | } 32 | 33 | impl Display for Method { 34 | #[inline] 35 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 | f.write_str(self.to_str()) 37 | } 38 | } 39 | 40 | #[cfg(test)] 41 | mod tests { 42 | use super::*; 43 | 44 | #[test] 45 | fn to_str() { 46 | assert_eq!(Method::Head.to_str(), "HEAD"); 47 | assert_eq!(Method::Get.to_str(), "GET"); 48 | assert_eq!(Method::Post.to_str(), "POST"); 49 | assert_eq!(Method::Put.to_str(), "PUT"); 50 | assert_eq!(Method::Delete.to_str(), "DELETE"); 51 | } 52 | 53 | #[test] 54 | fn display() { 55 | assert_eq!(Method::Head.to_string(), "HEAD"); 56 | assert_eq!(Method::Get.to_string(), "GET"); 57 | assert_eq!(Method::Post.to_string(), "POST"); 58 | assert_eq!(Method::Put.to_string(), "PUT"); 59 | assert_eq!(Method::Delete.to_string(), "DELETE"); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/signing/canonical_request.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use url::Url; 4 | 5 | use super::util::percent_encode; 6 | use crate::Method; 7 | 8 | const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; 9 | 10 | pub(super) fn canonical_request<'a, Q, H, S>( 11 | method: Method, 12 | url: &Url, 13 | query_string: Q, 14 | headers: H, 15 | signed_headers: S, 16 | ) -> String 17 | where 18 | Q: Iterator, 19 | H: Iterator, 20 | S: Iterator, 21 | { 22 | let mut string = String::with_capacity(64); 23 | string.push_str(method.to_str()); 24 | string.push('\n'); 25 | string.push_str(url.path()); 26 | string.push('\n'); 27 | 28 | canonical_query_string(query_string, &mut string).expect("String writer panicked"); 29 | 30 | string.push('\n'); 31 | 32 | canonical_headers(headers, &mut string).expect("String writer panicked"); 33 | 34 | string.push('\n'); 35 | 36 | signed_headers_(signed_headers, &mut string).expect("String writer panicked"); 37 | 38 | string.push('\n'); 39 | 40 | string.push_str(UNSIGNED_PAYLOAD); 41 | 42 | string 43 | } 44 | 45 | fn canonical_query_string<'a, Q>(query_string: Q, mut out: impl fmt::Write) -> fmt::Result 46 | where 47 | Q: Iterator, 48 | { 49 | let mut first = true; 50 | for (key, val) in query_string { 51 | if first { 52 | first = false; 53 | } else { 54 | out.write_char('&')?; 55 | } 56 | 57 | write!(out, "{}={}", percent_encode(key), percent_encode(val))?; 58 | } 59 | 60 | Ok(()) 61 | } 62 | 63 | fn canonical_headers<'a, H>(headers: H, mut out: impl fmt::Write) -> fmt::Result 64 | where 65 | H: Iterator, 66 | { 67 | for (key, val) in headers { 68 | out.write_str(key)?; 69 | out.write_char(':')?; 70 | out.write_str(val.trim())?; 71 | 72 | out.write_char('\n')?; 73 | } 74 | 75 | Ok(()) 76 | } 77 | 78 | fn signed_headers_<'a, H>(signed_headers: H, mut out: impl fmt::Write) -> fmt::Result 79 | where 80 | H: Iterator, 81 | { 82 | let mut first = true; 83 | for key in signed_headers { 84 | if first { 85 | first = false; 86 | } else { 87 | out.write_char(';')?; 88 | } 89 | 90 | out.write_str(key)?; 91 | } 92 | 93 | Ok(()) 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use pretty_assertions::assert_eq; 99 | 100 | use super::*; 101 | use crate::Method; 102 | 103 | #[test] 104 | fn aws_example() { 105 | // Fri, 24 May 2013 00:00:00 GMT 106 | 107 | let method = Method::Get; 108 | let url = "https://examplebucket.s3.amazonaws.com/test.txt" 109 | .parse() 110 | .unwrap(); 111 | let region = "us-east-1"; 112 | let key = "AKIAIOSFODNN7EXAMPLE"; 113 | let expires_seconds = 86400; 114 | 115 | let date_str = "20130524T000000Z"; 116 | let yyyymmdd = "20130524"; 117 | 118 | let credential = format!( 119 | "{}/{}/{}/{}/{}", 120 | key, yyyymmdd, region, "s3", "aws4_request" 121 | ); 122 | let signed_headers_str = "host"; 123 | 124 | let expected = concat!( 125 | "GET\n", 126 | "/test.txt\n", 127 | "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host\n", 128 | "host:examplebucket.s3.amazonaws.com\n", 129 | "\n", 130 | "host\n", 131 | "UNSIGNED-PAYLOAD", 132 | ); 133 | 134 | let got = canonical_request( 135 | method, 136 | &url, 137 | vec![ 138 | ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), 139 | ("X-Amz-Credential", &credential), 140 | ("X-Amz-Date", date_str), 141 | ("X-Amz-Expires", &expires_seconds.to_string()), 142 | ("X-Amz-SignedHeaders", signed_headers_str), 143 | ] 144 | .into_iter(), 145 | vec![("host", url.host_str().unwrap())].into_iter(), 146 | vec!["host"].into_iter(), 147 | ); 148 | 149 | assert_eq!(got, expected); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/signing/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{iter, str}; 2 | 3 | use jiff::Timestamp; 4 | use url::Url; 5 | 6 | use crate::sorting_iter::SortingIterator; 7 | use crate::time::{ISO8601, YYYYMMDD}; 8 | use crate::Method; 9 | 10 | mod canonical_request; 11 | mod signature; 12 | mod string_to_sign; 13 | pub(crate) mod util; 14 | 15 | /// Sign a URL with AWS Signature. 16 | /// 17 | /// # Panics 18 | /// 19 | /// If date format is invalid. 20 | #[allow( 21 | clippy::too_many_arguments, 22 | clippy::map_identity, 23 | clippy::option_if_let_else, 24 | clippy::single_match_else 25 | )] 26 | pub fn sign<'a, Q, H>( 27 | date: &Timestamp, 28 | method: Method, 29 | mut url: Url, 30 | key: &str, 31 | secret: &str, 32 | token: Option<&str>, 33 | region: &str, 34 | expires_seconds: u64, 35 | 36 | query_string: Q, 37 | headers: H, 38 | ) -> Url 39 | where 40 | Q: Iterator + Clone, 41 | H: Iterator + Clone, 42 | { 43 | // Convert `&'a str` into `&str`, in order to later be able to join them to 44 | // the inner iterators, which because of the references they take to the inner 45 | // `String`s, have a shorter lifetime than 'a. 46 | // Thanks to: https://t.me/rustlang_it/61993 47 | let query_string = query_string.map(|(k, value)| (k, value)); 48 | let headers = headers.map(|(k, value)| (k, value)); 49 | 50 | let yyyymmdd = date.strftime(&YYYYMMDD); 51 | 52 | let credential = format!( 53 | "{}/{}/{}/{}/{}", 54 | key, yyyymmdd, region, "s3", "aws4_request" 55 | ); 56 | let date_str = date.strftime(&ISO8601).to_string(); 57 | let expires_seconds_string = expires_seconds.to_string(); 58 | 59 | let host = url.host_str().expect("host is known"); 60 | let host_header = match (url.scheme(), url.port()) { 61 | ("http" | "https", None) | ("http", Some(80)) | ("https", Some(443)) => host.to_owned(), 62 | ("http" | "https", Some(port)) => { 63 | format!("{host}:{port}") 64 | } 65 | _ => panic!("unsupported url scheme"), 66 | }; 67 | 68 | let standard_headers = iter::once(("host", host_header.as_str())); 69 | let headers = SortingIterator::new(standard_headers, headers); 70 | 71 | let signed_headers = headers.clone().map(|(k, _)| k); 72 | let mut signed_headers_str = String::new(); 73 | for header in signed_headers.clone() { 74 | if !signed_headers_str.is_empty() { 75 | signed_headers_str.push(';'); 76 | } 77 | signed_headers_str.push_str(header); 78 | } 79 | 80 | let a1; 81 | let a2; 82 | let standard_query = match token { 83 | Some(token) => { 84 | a1 = [ 85 | ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), 86 | ("X-Amz-Credential", credential.as_str()), 87 | ("X-Amz-Date", date_str.as_str()), 88 | ("X-Amz-Expires", expires_seconds_string.as_str()), 89 | ("X-Amz-Security-Token", token), 90 | ("X-Amz-SignedHeaders", &signed_headers_str), 91 | ]; 92 | a1.iter() 93 | } 94 | None => { 95 | a2 = [ 96 | ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), 97 | ("X-Amz-Credential", credential.as_str()), 98 | ("X-Amz-Date", date_str.as_str()), 99 | ("X-Amz-Expires", expires_seconds_string.as_str()), 100 | ("X-Amz-SignedHeaders", &signed_headers_str), 101 | ]; 102 | a2.iter() 103 | } 104 | }; 105 | 106 | let query_string = SortingIterator::new(standard_query.copied(), query_string); 107 | 108 | { 109 | let mut query_pairs = url.query_pairs_mut(); 110 | query_pairs.clear(); 111 | 112 | query_pairs.extend_pairs(query_string.clone()); 113 | } 114 | 115 | let canonical_req = 116 | canonical_request::canonical_request(method, &url, query_string, headers, signed_headers); 117 | let signed_string = string_to_sign::string_to_sign(date, region, &canonical_req); 118 | let signature = signature::signature(date, secret, region, &signed_string); 119 | 120 | url.query_pairs_mut() 121 | .append_pair("X-Amz-Signature", &signature); 122 | url 123 | } 124 | 125 | #[cfg(test)] 126 | mod tests { 127 | use std::iter; 128 | 129 | use pretty_assertions::assert_eq; 130 | 131 | use super::Method; 132 | use super::*; 133 | 134 | #[test] 135 | fn aws_example() { 136 | // Fri, 24 May 2013 00:00:00 GMT 137 | let date = Timestamp::from_second(1369353600).unwrap(); 138 | 139 | let method = Method::Get; 140 | let url = "https://examplebucket.s3.amazonaws.com/test.txt" 141 | .parse() 142 | .unwrap(); 143 | let key = "AKIAIOSFODNN7EXAMPLE"; 144 | let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; 145 | let region = "us-east-1"; 146 | let expires_seconds = 86400; 147 | 148 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"; 149 | 150 | let got = sign( 151 | &date, 152 | method, 153 | url, 154 | key, 155 | secret, 156 | None, 157 | region, 158 | expires_seconds, 159 | iter::empty(), 160 | iter::empty(), 161 | ); 162 | 163 | assert_eq!(expected, got.as_str()); 164 | } 165 | 166 | #[test] 167 | fn aws_example_token() { 168 | // Fri, 24 May 2013 00:00:00 GMT 169 | let date = Timestamp::from_second(1369353600).unwrap(); 170 | 171 | let method = Method::Get; 172 | let url = "https://examplebucket.s3.amazonaws.com/test.txt" 173 | .parse() 174 | .unwrap(); 175 | let key = "AKIAIOSFODNN7EXAMPLE"; 176 | let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; 177 | let token = "oej5cie4uctureturdtuc5dctd"; 178 | let region = "us-east-1"; 179 | let expires_seconds = 86400; 180 | 181 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-Security-Token=oej5cie4uctureturdtuc5dctd&X-Amz-SignedHeaders=host&X-Amz-Signature=bf77b83a7135594046c90a7e7e10cf1a4c8f8ecc1d541d0f42bea6b7670870c7"; 182 | 183 | let got = sign( 184 | &date, 185 | method, 186 | url, 187 | key, 188 | secret, 189 | Some(token), 190 | region, 191 | expires_seconds, 192 | iter::empty(), 193 | iter::empty(), 194 | ); 195 | 196 | assert_eq!(expected, got.as_str()); 197 | } 198 | 199 | #[test] 200 | fn aws_headers_example() { 201 | // Fri, 24 May 2013 00:00:00 GMT 202 | let date = Timestamp::from_second(1369353600).unwrap(); 203 | 204 | let method = Method::Get; 205 | let url = "https://examplebucket.s3.amazonaws.com/test.txt" 206 | .parse() 207 | .unwrap(); 208 | let key = "AKIAIOSFODNN7EXAMPLE"; 209 | let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; 210 | let region = "us-east-1"; 211 | let expires_seconds = 86400; 212 | 213 | let expected = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=content-type%3Bhost%3Bx-amz-date&X-Amz-Signature=e965ee011ab5dbe8aa2c04a1ff2db8503c0cc117f62ea9274415c0f593ea199f"; 214 | 215 | let headers = [ 216 | ( 217 | "content-type", 218 | "application/x-www-form-urlencoded; charset=utf-8", 219 | ), 220 | ("x-amz-date", "20150830T123600Z"), 221 | ]; 222 | 223 | let got = sign( 224 | &date, 225 | method, 226 | url, 227 | key, 228 | secret, 229 | None, 230 | region, 231 | expires_seconds, 232 | iter::empty(), 233 | headers.iter().copied(), 234 | ); 235 | 236 | assert_eq!(expected, got.as_str()); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/signing/signature.rs: -------------------------------------------------------------------------------- 1 | use hmac::{Hmac, Mac as _}; 2 | use jiff::Timestamp; 3 | use sha2::Sha256; 4 | use zeroize::Zeroizing; 5 | 6 | use crate::time::YYYYMMDD; 7 | 8 | type HmacSha256 = Hmac; 9 | 10 | pub(super) fn signature( 11 | date: &Timestamp, 12 | secret: &str, 13 | region: &str, 14 | string_to_sign: &str, 15 | ) -> String { 16 | let yyyymmdd = date.strftime(&YYYYMMDD).to_string(); 17 | 18 | let mut raw_date = String::with_capacity("AWS4".len() + secret.len()); 19 | raw_date.push_str("AWS4"); 20 | raw_date.push_str(secret); 21 | let raw_date = Zeroizing::new(raw_date); 22 | 23 | let mut mac = 24 | HmacSha256::new_from_slice(raw_date.as_bytes()).expect("HMAC can take keys of any size"); 25 | mac.update(yyyymmdd.as_bytes()); 26 | let date_key = mac.finalize().into_bytes(); 27 | 28 | let mut mac = HmacSha256::new_from_slice(&date_key).expect("HMAC can take keys of any size"); 29 | mac.update(region.as_bytes()); 30 | let date_region_key = mac.finalize().into_bytes(); 31 | 32 | let mut mac = 33 | HmacSha256::new_from_slice(&date_region_key).expect("HMAC can take keys of any size"); 34 | mac.update(b"s3"); 35 | let date_region_service_key = mac.finalize().into_bytes(); 36 | 37 | let mut mac = HmacSha256::new_from_slice(&date_region_service_key) 38 | .expect("HMAC can take keys of any size"); 39 | mac.update(b"aws4_request"); 40 | let signing_key = mac.finalize().into_bytes(); 41 | 42 | let mut mac = HmacSha256::new_from_slice(&signing_key).expect("HMAC can take keys of any size"); 43 | mac.update(string_to_sign.as_bytes()); 44 | format!("{:x}", mac.finalize().into_bytes()) 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use pretty_assertions::assert_eq; 50 | 51 | use super::*; 52 | 53 | #[test] 54 | fn aws_example() { 55 | // Fri, 24 May 2013 00:00:00 GMT 56 | let date = Timestamp::from_second(1369353600).unwrap(); 57 | 58 | let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; 59 | let region = "us-east-1"; 60 | 61 | let expected = "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"; 62 | 63 | let got = signature(&date, secret, region, create_string_to_sign()); 64 | 65 | assert_eq!(got, expected); 66 | } 67 | 68 | fn create_string_to_sign() -> &'static str { 69 | concat!( 70 | "AWS4-HMAC-SHA256\n", 71 | "20130524T000000Z\n", 72 | "20130524/us-east-1/s3/aws4_request\n", 73 | "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04" 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/signing/string_to_sign.rs: -------------------------------------------------------------------------------- 1 | use jiff::Timestamp; 2 | use sha2::{Digest as _, Sha256}; 3 | 4 | use crate::time::{ISO8601, YYYYMMDD}; 5 | 6 | pub(super) fn string_to_sign(date: &Timestamp, region: &str, canonical_request: &str) -> String { 7 | let iso8601 = date.strftime(&ISO8601); 8 | let yyyymmdd = date.strftime(&YYYYMMDD); 9 | 10 | let scope = format!("{yyyymmdd}/{region}/s3/aws4_request"); 11 | 12 | let hash = Sha256::digest(canonical_request.as_bytes()); 13 | format!("AWS4-HMAC-SHA256\n{iso8601}\n{scope}\n{hash:x}") 14 | } 15 | 16 | #[cfg(test)] 17 | mod tests { 18 | use pretty_assertions::assert_eq; 19 | 20 | use super::*; 21 | 22 | #[test] 23 | fn aws_example() { 24 | // Fri, 24 May 2013 00:00:00 GMT 25 | let date = Timestamp::from_second(1369353600).unwrap(); 26 | 27 | let region = "us-east-1"; 28 | 29 | let expected = concat!( 30 | "AWS4-HMAC-SHA256\n", 31 | "20130524T000000Z\n", 32 | "20130524/us-east-1/s3/aws4_request\n", 33 | "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04" 34 | ); 35 | 36 | let got = string_to_sign(&date, region, create_canonical_request()); 37 | 38 | assert_eq!(got, expected); 39 | } 40 | 41 | fn create_canonical_request() -> &'static str { 42 | concat!( 43 | "GET\n", 44 | "/test.txt\n", 45 | "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host\n", 46 | "host:examplebucket.s3.amazonaws.com\n", 47 | "\n", 48 | "host\n", 49 | "UNSIGNED-PAYLOAD", 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/signing/util.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Display}; 2 | 3 | use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; 4 | use url::Url; 5 | 6 | // https://perishablepress.com/stop-using-unsafe-characters-in-urls/ 7 | pub(crate) const FRAGMENT: &AsciiSet = &CONTROLS 8 | // URL_RESERVED 9 | .add(b':') 10 | .add(b'?') 11 | .add(b'#') 12 | .add(b'[') 13 | .add(b']') 14 | .add(b'@') 15 | .add(b'!') 16 | .add(b'$') 17 | .add(b'&') 18 | .add(b'\'') 19 | .add(b'(') 20 | .add(b')') 21 | .add(b'*') 22 | .add(b'+') 23 | .add(b',') 24 | .add(b';') 25 | .add(b'=') 26 | // URL_UNSAFE 27 | .add(b'"') 28 | .add(b' ') 29 | .add(b'<') 30 | .add(b'>') 31 | .add(b'%') 32 | .add(b'{') 33 | .add(b'}') 34 | .add(b'|') 35 | .add(b'\\') 36 | .add(b'^') 37 | .add(b'`'); 38 | 39 | pub(crate) const FRAGMENT_SLASH: &AsciiSet = &FRAGMENT.add(b'/'); 40 | 41 | pub(crate) fn percent_encode(val: &str) -> impl Display + Into> + '_ { 42 | utf8_percent_encode(val, FRAGMENT_SLASH) 43 | } 44 | 45 | pub(crate) fn percent_encode_path(val: &str) -> impl Display + Into> + '_ { 46 | utf8_percent_encode(val, FRAGMENT) 47 | } 48 | 49 | pub(crate) fn add_query_params<'a, Q>(mut url: Url, params: Q) -> Url 50 | where 51 | Q: Iterator, 52 | { 53 | let mut query_pairs = url.query_pairs_mut(); 54 | query_pairs.extend_pairs(params); 55 | drop(query_pairs); 56 | 57 | url 58 | } 59 | -------------------------------------------------------------------------------- /src/sorting_iter.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ord; 2 | use std::iter::{Fuse, FusedIterator}; 3 | 4 | #[derive(Debug, Clone)] 5 | pub(crate) struct SortingIterator 6 | where 7 | A: Iterator, 8 | B: Iterator, 9 | { 10 | a: Fuse, 11 | b: Fuse, 12 | 13 | a_buffer: Option, 14 | b_buffer: Option, 15 | } 16 | 17 | impl SortingIterator 18 | where 19 | A: Iterator, 20 | B: Iterator, 21 | { 22 | pub(crate) fn new(a: A, b: B) -> Self { 23 | Self { 24 | a: a.fuse(), 25 | b: b.fuse(), 26 | 27 | a_buffer: None, 28 | b_buffer: None, 29 | } 30 | } 31 | } 32 | 33 | impl Iterator for SortingIterator 34 | where 35 | A: Iterator, 36 | B: Iterator, 37 | A::Item: Ord, 38 | B::Item: Ord, 39 | { 40 | type Item = A::Item; 41 | 42 | fn next(&mut self) -> Option { 43 | let a_next = self.a_buffer.take().or_else(|| self.a.next()); 44 | let b_next = self.b_buffer.take().or_else(|| self.b.next()); 45 | 46 | match (a_next, b_next) { 47 | (Some(a_next), Some(b_next)) if a_next < b_next => { 48 | self.b_buffer = Some(b_next); 49 | Some(a_next) 50 | } 51 | (Some(a_next), Some(b_next)) => { 52 | self.a_buffer = Some(a_next); 53 | Some(b_next) 54 | } 55 | (Some(next), None) | (None, Some(next)) => Some(next), 56 | (None, None) => None, 57 | } 58 | } 59 | } 60 | 61 | impl FusedIterator for SortingIterator 62 | where 63 | A: Iterator, 64 | B: Iterator, 65 | A::Item: Ord, 66 | B::Item: Ord, 67 | { 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use std::iter; 73 | 74 | use super::*; 75 | 76 | #[test] 77 | fn empty() { 78 | let mut iter = SortingIterator::new(iter::empty::(), iter::empty::()); 79 | assert!(iter.next().is_none()); 80 | } 81 | 82 | #[test] 83 | fn numbers() { 84 | let a = vec![10, 20, 25, 30, 40]; 85 | let b = vec![5, 9, 15, 35, 45, 50]; 86 | 87 | let iter = SortingIterator::new(a.into_iter(), b.into_iter()); 88 | assert!(iter.eq(vec![5, 9, 10, 15, 20, 25, 30, 35, 40, 45, 50].into_iter())) 89 | } 90 | 91 | #[test] 92 | fn numbers_empty() { 93 | let a = vec![10, 20, 25, 30, 40]; 94 | 95 | let iter = SortingIterator::new(a.into_iter(), iter::empty()); 96 | assert!(iter.eq(vec![10, 20, 25, 30, 40].into_iter())) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/time.rs: -------------------------------------------------------------------------------- 1 | /// The format used by the `Date` header. 2 | pub(crate) const ISO8601: &str = "%Y%m%dT%H%M%SZ"; 3 | 4 | /// The format used by the `x-amz-date` header. 5 | pub(crate) const YYYYMMDD: &str = "%Y%m%d"; 6 | -------------------------------------------------------------------------------- /tests/common.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use reqwest::Client; 4 | use rusty_s3::actions::{CreateBucket, S3Action as _}; 5 | use rusty_s3::{Bucket, Credentials, UrlStyle}; 6 | 7 | pub(crate) async fn bucket() -> (Bucket, Credentials, Client) { 8 | let mut buf = [0; 8]; 9 | getrandom::getrandom(&mut buf).expect("getrandom"); 10 | 11 | let hex = hex::encode(buf); 12 | let name = format!("test-{hex}"); 13 | 14 | let url = "http://localhost:9000".parse().unwrap(); 15 | let key = "minioadmin"; 16 | let secret = "minioadmin"; 17 | let region = "minio"; 18 | 19 | let bucket = Bucket::new(url, UrlStyle::Path, name, region).unwrap(); 20 | let credentials = Credentials::new(key, secret); 21 | 22 | let client = Client::new(); 23 | let action = CreateBucket::new(&bucket, &credentials); 24 | let url = action.sign(Duration::from_secs(60)); 25 | client 26 | .put(url) 27 | .send() 28 | .await 29 | .expect("send CreateBucket request") 30 | .error_for_status() 31 | .expect("CreateBucket request unexpected status code"); 32 | 33 | (bucket, credentials, client) 34 | } 35 | -------------------------------------------------------------------------------- /tests/create_delete_bucket.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use rusty_s3::actions::S3Action as _; 4 | 5 | mod common; 6 | 7 | #[tokio::test] 8 | async fn test2() { 9 | let (bucket, credentials, client) = common::bucket().await; 10 | 11 | let action = bucket.delete_bucket(&credentials); 12 | let url = action.sign(Duration::from_secs(60)); 13 | client 14 | .delete(url) 15 | .send() 16 | .await 17 | .expect("send DeleteBucket") 18 | .error_for_status() 19 | .expect("DeleteBucket unexpected status code"); 20 | } 21 | -------------------------------------------------------------------------------- /tests/delete_objects.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use reqwest::Client; 4 | use url::Url; 5 | 6 | use rusty_s3::actions::{ListObjectsV2, ListObjectsV2Response, ObjectIdentifier, S3Action as _}; 7 | 8 | mod common; 9 | 10 | #[tokio::test] 11 | async fn delete_objects() { 12 | let (bucket, credentials, client) = common::bucket().await; 13 | 14 | let action = bucket.list_objects_v2(Some(&credentials)); 15 | let list_url = action.sign(Duration::from_secs(600)); 16 | let list = get_objects_list(&client, list_url.clone()).await; 17 | assert!(list.contents.is_empty()); 18 | 19 | // Fill bucket by objects 20 | let body = vec![b'r'; 1024]; 21 | let mut objects = vec![]; 22 | for i in 0..100 { 23 | let key = format!("obj{i}.txt"); 24 | let action = bucket.put_object(Some(&credentials), &key); 25 | let url = action.sign(Duration::from_secs(60)); 26 | client 27 | .put(url) 28 | .body(body.clone()) 29 | .send() 30 | .await 31 | .expect("send PutObject") 32 | .error_for_status() 33 | .expect("PutObject unexpected status code"); 34 | objects.push(ObjectIdentifier::new(key)); 35 | } 36 | 37 | let list = get_objects_list(&client, list_url.clone()).await; 38 | assert_eq!(list.contents.len(), 100); 39 | 40 | let action = bucket.delete_objects(Some(&credentials), objects.iter()); 41 | let url = action.sign(Duration::from_secs(60)); 42 | let (body, content_md5) = action.body_with_md5(); 43 | client 44 | .post(url) 45 | .header("Content-MD5", content_md5) 46 | .body(body) 47 | .send() 48 | .await 49 | .expect("send DeleteObjects") 50 | .error_for_status() 51 | .expect("DeleteObjects unexpected status code"); 52 | 53 | let list = get_objects_list(&client, list_url.clone()).await; 54 | assert!(list.contents.is_empty()); 55 | } 56 | 57 | async fn get_objects_list(client: &Client, url: Url) -> ListObjectsV2Response { 58 | let resp = client 59 | .get(url) 60 | .send() 61 | .await 62 | .expect("send ListObjectsV2") 63 | .error_for_status() 64 | .expect("ListObjectsV2 unexpected status code"); 65 | let text = resp.text().await.expect("ListObjectsV2 read response body"); 66 | ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response") 67 | } 68 | -------------------------------------------------------------------------------- /tests/list_parts.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | use std::time::Duration; 3 | 4 | use rusty_s3::actions::{CreateMultipartUpload, ListParts, ListPartsResponse, S3Action as _}; 5 | 6 | mod common; 7 | 8 | #[tokio::test] 9 | async fn list_parts() { 10 | let (bucket, credentials, client) = common::bucket().await; 11 | 12 | // Create multipart upload 13 | let key = "list_parts"; 14 | let action = bucket.create_multipart_upload(Some(&credentials), key); 15 | let url = action.sign(Duration::from_secs(60)); 16 | let text = client 17 | .post(url) 18 | .send() 19 | .await 20 | .expect("send CreateMultipartUpload") 21 | .error_for_status() 22 | .expect("CreateMultipartUpload unexpected status code") 23 | .text() 24 | .await 25 | .expect("CreateMultipartUpload read response body"); 26 | let upload = 27 | CreateMultipartUpload::parse_response(&text).expect("CreateMultipartUpload parse response"); 28 | let upload_id = upload.upload_id(); 29 | 30 | // Upload some parts 31 | let part_size: usize = 5 * 1024 * 1024; 32 | let body = vec![b'r'; part_size]; 33 | for part_num in 1..=3u16 { 34 | let action = bucket.upload_part(Some(&credentials), key, part_num, upload_id); 35 | let url = action.sign(Duration::from_secs(60)); 36 | client 37 | .put(url) 38 | .body(body.clone()) 39 | .send() 40 | .await 41 | .expect("send UploadPart") 42 | .error_for_status() 43 | .expect("UploadPart unexpected status code"); 44 | } 45 | 46 | // Get list of parts 47 | let mut action = bucket.list_parts(Some(&credentials), key, upload_id); 48 | action.set_max_parts(2); 49 | let parts = get_list_of_parts(&client, action).await; 50 | assert_eq!(parts.parts.len(), 2); 51 | assert_eq!(parts.max_parts, 2); 52 | assert_eq!(parts.next_part_number_marker, Some(2)); 53 | for part in &parts.parts { 54 | assert_eq!(part.size, part_size as u64); 55 | assert_eq!(part.etag, "\"0551556e17bba4b6c9dfbaab9e6f08dd\""); 56 | } 57 | 58 | let mut action = bucket.list_parts(Some(&credentials), key, upload_id); 59 | action.set_part_number_marker(parts.next_part_number_marker.unwrap()); 60 | let parts = get_list_of_parts(&client, action).await; 61 | assert_eq!(parts.parts.len(), 1); 62 | assert!(parts.next_part_number_marker.is_none()); 63 | for part in &parts.parts { 64 | assert_eq!(part.size, part_size as u64); 65 | assert_eq!(part.etag, "\"0551556e17bba4b6c9dfbaab9e6f08dd\""); 66 | } 67 | } 68 | 69 | async fn get_list_of_parts(client: &Client, action: ListParts<'_>) -> ListPartsResponse { 70 | let url = action.sign(Duration::from_secs(60)); 71 | let text = client 72 | .get(url) 73 | .send() 74 | .await 75 | .expect("send ListParts") 76 | .error_for_status() 77 | .expect("ListParts unexpected status code") 78 | .text() 79 | .await 80 | .expect("ListParts read response body"); 81 | ListParts::parse_response(&text).expect("ListParts parse response") 82 | } 83 | -------------------------------------------------------------------------------- /tests/upload_download.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use rusty_s3::actions::{ListObjectsV2, S3Action as _}; 4 | 5 | mod common; 6 | 7 | #[tokio::test] 8 | async fn test1() { 9 | let (bucket, credentials, client) = common::bucket().await; 10 | 11 | let action = bucket.list_objects_v2(Some(&credentials)); 12 | let url = action.sign(Duration::from_secs(60)); 13 | let resp = client 14 | .get(url) 15 | .send() 16 | .await 17 | .expect("send ListObjectsV2") 18 | .error_for_status() 19 | .expect("ListObjectsV2 unexpected status code"); 20 | let text = resp.text().await.expect("ListObjectsV2 read respose body"); 21 | let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response"); 22 | 23 | assert!(list.contents.is_empty()); 24 | 25 | assert_eq!(list.max_keys, Some(1000)); 26 | assert!(list.common_prefixes.is_empty()); 27 | assert!(list.next_continuation_token.is_none()); 28 | assert!(list.start_after.is_none()); 29 | 30 | let body = vec![b'r'; 1024]; 31 | 32 | let action = bucket.put_object(Some(&credentials), "test.txt"); 33 | let url = action.sign(Duration::from_secs(60)); 34 | client 35 | .put(url) 36 | .body(body.clone()) 37 | .send() 38 | .await 39 | .expect("send PutObject") 40 | .error_for_status() 41 | .expect("PutObject unexpected status code"); 42 | 43 | let action = bucket.head_object(Some(&credentials), "test.txt"); 44 | let url = action.sign(Duration::from_secs(60)); 45 | 46 | let resp = client 47 | .head(url) 48 | .send() 49 | .await 50 | .expect("send HeadObject") 51 | .error_for_status() 52 | .expect("HeadObject unexpected status code"); 53 | 54 | let content_length = resp 55 | .headers() 56 | .get("content-length") 57 | .expect("Content-Length header") 58 | .to_str() 59 | .expect("Content-Length to_str()"); 60 | assert_eq!(content_length, "1024"); 61 | 62 | let action = bucket.get_object(Some(&credentials), "test.txt"); 63 | let url = action.sign(Duration::from_secs(60)); 64 | 65 | let resp = client 66 | .get(url) 67 | .send() 68 | .await 69 | .expect("send GetObject") 70 | .error_for_status() 71 | .expect("GetObject unexpected status code"); 72 | let bytes = resp.bytes().await.expect("GetObject read response body"); 73 | 74 | assert_eq!(body, bytes); 75 | } 76 | 77 | #[tokio::test] 78 | async fn test_headers() { 79 | let (bucket, credentials, client) = common::bucket().await; 80 | 81 | let action = bucket.list_objects_v2(Some(&credentials)); 82 | let url = action.sign(Duration::from_secs(60)); 83 | let resp = client 84 | .get(url) 85 | .send() 86 | .await 87 | .expect("send ListObjectsV2") 88 | .error_for_status() 89 | .expect("ListObjectsV2 unexpected status code"); 90 | let text = resp.text().await.expect("ListObjectsV2 read respose body"); 91 | let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response"); 92 | 93 | assert!(list.contents.is_empty()); 94 | 95 | assert_eq!(list.max_keys, Some(1000)); 96 | assert!(list.common_prefixes.is_empty()); 97 | assert!(list.next_continuation_token.is_none()); 98 | assert!(list.start_after.is_none()); 99 | 100 | let body = vec![b'r'; 1024]; 101 | 102 | let mut action = bucket.put_object(Some(&credentials), "test.txt"); 103 | action.headers_mut().insert("content-type", "animal/duck"); 104 | let url = action.sign(Duration::from_secs(60)); 105 | client 106 | .put(url) 107 | .header("content-type", "animal/duck") 108 | .body(body.clone()) 109 | .send() 110 | .await 111 | .expect("send PutObject") 112 | .error_for_status() 113 | .expect("PutObject unexpected status code"); 114 | 115 | let action = bucket.get_object(Some(&credentials), "test.txt"); 116 | let url = action.sign(Duration::from_secs(60)); 117 | 118 | let resp = client 119 | .get(url) 120 | .send() 121 | .await 122 | .expect("send GetObject") 123 | .error_for_status() 124 | .expect("GetObject unexpected status code"); 125 | 126 | assert_eq!( 127 | resp.headers() 128 | .get("content-type") 129 | .unwrap() 130 | .to_str() 131 | .unwrap(), 132 | "animal/duck" 133 | ); 134 | 135 | let bytes = resp.bytes().await.expect("GetObject read response body"); 136 | 137 | assert_eq!(body, bytes); 138 | } 139 | --------------------------------------------------------------------------------