rusty-s3 0.10.2

Simple pure Rust AWS S3 Client following a Sans-IO approach
Documentation
use std::{iter, str};

use jiff::Timestamp;
use url::Url;

use crate::Method;
use crate::sorting_iter::SortingIterator;
use crate::time::{ISO8601, YYYYMMDD};

mod canonical_request;
mod signature;
mod string_to_sign;
pub(crate) mod util;

/// Sign a URL with AWS Signature.
///
/// # Panics
///
/// If date format is invalid.
#[allow(
    clippy::too_many_arguments,
    clippy::map_identity,
    clippy::option_if_let_else,
    clippy::single_match_else
)]
pub fn sign<'a, Q, H>(
    date: &Timestamp,
    method: Method,
    mut url: Url,
    key: &str,
    secret: &str,
    token: Option<&str>,
    region: &str,
    expires_seconds: u64,

    query_string: Q,
    headers: H,
) -> Url
where
    Q: Iterator<Item = (&'a str, &'a str)> + Clone,
    H: Iterator<Item = (&'a str, &'a str)> + Clone,
{
    // Convert `&'a str` into `&str`, in order to later be able to join them to
    // the inner iterators, which because of the references they take to the inner
    // `String`s, have a shorter lifetime than 'a.
    // Thanks to: https://t.me/rustlang_it/61993
    let query_string = query_string.map(|(k, value)| (k, value));
    let headers = headers.map(|(k, value)| (k, value));

    let yyyymmdd = date.strftime(&YYYYMMDD);

    let credential = format!(
        "{}/{}/{}/{}/{}",
        key, yyyymmdd, region, "s3", "aws4_request"
    );
    let date_str = date.strftime(&ISO8601).to_string();
    let expires_seconds_string = expires_seconds.to_string();

    let host = url.host_str().expect("host is known");
    let host_header = match (url.scheme(), url.port()) {
        ("http" | "https", None) | ("http", Some(80)) | ("https", Some(443)) => host.to_owned(),
        ("http" | "https", Some(port)) => {
            format!("{host}:{port}")
        }
        _ => panic!("unsupported url scheme"),
    };

    let standard_headers = iter::once(("host", host_header.as_str()));
    let headers = SortingIterator::new(standard_headers, headers);

    let signed_headers = headers.clone().map(|(k, _)| k);
    let mut signed_headers_str = String::new();
    for header in signed_headers.clone() {
        if !signed_headers_str.is_empty() {
            signed_headers_str.push(';');
        }
        signed_headers_str.push_str(header);
    }

    let a1;
    let a2;
    let standard_query = match token {
        Some(token) => {
            a1 = [
                ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
                ("X-Amz-Credential", credential.as_str()),
                ("X-Amz-Date", date_str.as_str()),
                ("X-Amz-Expires", expires_seconds_string.as_str()),
                ("X-Amz-Security-Token", token),
                ("X-Amz-SignedHeaders", &signed_headers_str),
            ];
            a1.iter()
        }
        None => {
            a2 = [
                ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
                ("X-Amz-Credential", credential.as_str()),
                ("X-Amz-Date", date_str.as_str()),
                ("X-Amz-Expires", expires_seconds_string.as_str()),
                ("X-Amz-SignedHeaders", &signed_headers_str),
            ];
            a2.iter()
        }
    };

    let query_string = SortingIterator::new(standard_query.copied(), query_string);

    let canonical_req = canonical_request::canonical_request(
        method,
        &url,
        query_string.clone(),
        headers,
        signed_headers,
    );
    let signed_string = string_to_sign::string_to_sign(date, region, &canonical_req);
    let signature = signature::signature(date, secret, region, &signed_string);

    // Build the URL query string with the same RFC 3986 percent-encoding used
    // for the canonical request (a space is `%20`, not `+`), so the emitted URL
    // matches exactly what was signed. `Url::query_pairs_mut` must not be used
    // here: it serializes as `application/x-www-form-urlencoded`, which encodes
    // spaces as `+` and would invalidate the signature.
    let mut query = String::new();
    util::canonical_query_string(query_string, &mut query).expect("String writer panicked");
    query.push_str("&X-Amz-Signature=");
    query.push_str(&signature);
    url.set_query(Some(&query));

    url
}

#[cfg(test)]
mod tests {
    use std::iter;

    use pretty_assertions::assert_eq;

    use super::Method;
    use super::*;

    #[test]
    fn aws_example() {
        // Fri, 24 May 2013 00:00:00 GMT
        let date = Timestamp::from_second(1369353600).unwrap();

        let method = Method::Get;
        let url = "https://examplebucket.s3.amazonaws.com/test.txt"
            .parse()
            .unwrap();
        let key = "AKIAIOSFODNN7EXAMPLE";
        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
        let region = "us-east-1";
        let expires_seconds = 86400;

        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";

        let got = sign(
            &date,
            method,
            url,
            key,
            secret,
            None,
            region,
            expires_seconds,
            iter::empty(),
            iter::empty(),
        );

        assert_eq!(expected, got.as_str());
    }

    #[test]
    fn aws_example_token() {
        // Fri, 24 May 2013 00:00:00 GMT
        let date = Timestamp::from_second(1369353600).unwrap();

        let method = Method::Get;
        let url = "https://examplebucket.s3.amazonaws.com/test.txt"
            .parse()
            .unwrap();
        let key = "AKIAIOSFODNN7EXAMPLE";
        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
        let token = "oej5cie4uctureturdtuc5dctd";
        let region = "us-east-1";
        let expires_seconds = 86400;

        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";

        let got = sign(
            &date,
            method,
            url,
            key,
            secret,
            Some(token),
            region,
            expires_seconds,
            iter::empty(),
            iter::empty(),
        );

        assert_eq!(expected, got.as_str());
    }

    #[test]
    fn aws_headers_example() {
        // Fri, 24 May 2013 00:00:00 GMT
        let date = Timestamp::from_second(1369353600).unwrap();

        let method = Method::Get;
        let url = "https://examplebucket.s3.amazonaws.com/test.txt"
            .parse()
            .unwrap();
        let key = "AKIAIOSFODNN7EXAMPLE";
        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
        let region = "us-east-1";
        let expires_seconds = 86400;

        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";

        let headers = [
            (
                "content-type",
                "application/x-www-form-urlencoded; charset=utf-8",
            ),
            ("x-amz-date", "20150830T123600Z"),
        ];

        let got = sign(
            &date,
            method,
            url,
            key,
            secret,
            None,
            region,
            expires_seconds,
            iter::empty(),
            headers.iter().copied(),
        );

        assert_eq!(expected, got.as_str());
    }

    #[test]
    fn query_value_with_space_is_percent_encoded() {
        // Fri, 24 May 2013 00:00:00 GMT
        let date = Timestamp::from_second(1369353600).unwrap();

        let url = "https://examplebucket.s3.amazonaws.com/test.txt"
            .parse()
            .unwrap();

        let query = [("response-content-disposition", "attachment; x y")];
        let got = sign(
            &date,
            Method::Get,
            url,
            "AKIAIOSFODNN7EXAMPLE",
            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            None,
            "us-east-1",
            86400,
            query.iter().copied(),
            iter::empty(),
        );

        // The emitted URL must encode spaces as `%20` (matching the canonical
        // request), never `+` (form-encoding), otherwise the signature breaks.
        let got = got.as_str();
        assert!(
            got.contains("response-content-disposition=attachment%3B%20x%20y"),
            "got: {got}"
        );
        assert!(!got.contains('+'), "query unexpectedly contains '+': {got}");
    }
}