s3m-core 0.18.0

Reusable S3 client and streaming engine behind the s3m CLI
Documentation
pub mod actions;
pub mod checksum;
pub mod credentials;
pub mod error;
pub mod limits;
pub mod options;
pub mod region;
pub mod request;
pub mod responses;
pub mod signature;
pub mod tools;
pub use self::{
    credentials::Credentials,
    error::{ApiError, Error},
    options::RequestOptions,
    region::Region,
    signature::Signature,
};

use crate::s3::tools::{sha256_digest, write_hex_bytes};
use anyhow::Result;
use reqwest::Client;
use std::fmt;
use url::Url;

#[derive(Debug, Clone)]
pub struct S3 {
    // AWS Credentials
    credentials: Credentials,
    // AWS Region
    region: Region,
    // bucket name
    bucket: Option<String>,
    // sign request
    no_sign_request: bool,
    // shared HTTP client for connection pooling and TLS session reuse
    client: Client,
}

impl fmt::Display for S3 {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Write the formatted representation to the provided formatter
        write!(
            f,
            "credentials: {:#?}\nregion: {:#?}\nbucket: {:#?}\nno_sign_request: {}",
            self.credentials, self.region, self.bucket, self.no_sign_request
        )
    }
}

// Amazon S3 API Reference
// <https://docs.aws.amazon.com/AmazonS3/latest/API/API_Operations.html>
impl S3 {
    #[must_use]
    pub fn new(
        credentials: &Credentials,
        region: &Region,
        bucket: Option<String>,
        no_sign_request: bool,
    ) -> Self {
        Self {
            credentials: credentials.clone(),
            region: region.clone(),
            bucket,
            no_sign_request,
            client: Client::new(),
        }
    }

    #[must_use]
    pub const fn client(&self) -> &Client {
        &self.client
    }

    #[must_use]
    pub fn bucket(&self) -> Option<&str> {
        self.bucket.as_deref()
    }

    #[must_use]
    pub fn with_bucket(&self, bucket: Option<String>) -> Self {
        let mut s3 = self.clone();
        s3.bucket = bucket;
        s3
    }

    // use it to identify the connection and keep track of the uploaded files so that the same file
    // could be uploaded into multiple provider/buckets
    #[must_use]
    pub fn hash(&self) -> String {
        let mut hash = String::new();

        hash.push_str(self.credentials.aws_access_key_id());
        hash.push_str(self.credentials.aws_secret_access_key());
        hash.push_str(&self.region.endpoint());

        if let Some(bucket) = &self.bucket {
            hash.push_str(bucket);
        }

        write_hex_bytes(sha256_digest(&hash).as_ref())
    }

    /// # Errors
    /// Will return an error if the endpoint is invalid
    pub fn endpoint(&self) -> Result<Url> {
        let endpoint_str = self.region.endpoint();

        // Check if endpoint already has a scheme (http:// or https://)
        let base_url =
            if endpoint_str.starts_with("http://") || endpoint_str.starts_with("https://") {
                // Endpoint already has scheme, use it as-is
                endpoint_str
            } else {
                // No scheme, prepend https://
                format!("https://{endpoint_str}")
            };

        let url = if let Some(bucket) = &self.bucket {
            Url::parse(&format!("{base_url}/{bucket}"))?
        } else {
            Url::parse(&base_url)?
        };

        Ok(url)
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::unnecessary_wraps
)]
mod tests {
    use super::*;
    use secrecy::SecretString;

    #[test]
    fn test_s3() {
        let s3 = S3::new(
            &Credentials::new(
                "AKIAIOSFODNN7EXAMPLE",
                &SecretString::new("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into()),
            ),
            &"us-west-1".parse::<Region>().unwrap(),
            Some("awsexamplebucket1".to_string()),
            false,
        );

        assert_eq!(
            s3.hash(),
            "bc8cea3065fd34a0b9cda467e3ace6bae7737d046e0dd3d449381a33508c24ae"
        );
    }

    #[test]
    fn test_s3_endpoint() {
        let s3 = S3::new(
            &Credentials::new(
                "AKIAIOSFODNN7EXAMPLE",
                &SecretString::new("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into()),
            ),
            &"us-west-1".parse::<Region>().unwrap(),
            Some("awsexamplebucket1".to_string()),
            false,
        );

        assert_eq!(
            s3.endpoint().unwrap().as_str(),
            "https://s3.us-west-1.amazonaws.com/awsexamplebucket1"
        );
    }
}