rusty-oss 0.2.1

Simple pure Rust Aliyun OSS Client following a Sans-IO approach
Documentation
use std::fmt;

use url::Url;

use crate::Method;

use super::util::percent_encode;

const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";

pub fn canonical_request<'a, Q, H, S>(
    method: Method,
    url: &Url,
    query_string: Q,
    headers: H,
    signed_headers: S,
) -> String
where
    Q: Iterator<Item = (&'a str, &'a str)>,
    H: Iterator<Item = (&'a str, &'a str)>,
    S: Iterator<Item = &'a str>,
{
    let mut string = String::with_capacity(64);

    // Add method and path
    string.push_str(method.to_str());
    string.push('\n');

    let path = parse_path(url);
    string.push_str(&path);
    string.push('\n');

    // Canonical query string
    canonical_query_string(query_string, &mut string).unwrap();
    string.push('\n');

    // Canonical headers
    canonical_headers(headers, &mut string).unwrap();
    string.push('\n');

    // Canonical signed headers
    canonical_signed_headers(signed_headers, &mut string).unwrap();
    string.push('\n');

    // Add UNSIGNED_PAYLOAD
    string.push_str(UNSIGNED_PAYLOAD);

    string
}

fn parse_path(url: &Url) -> String {
    let host_str = url
        .host_str()
        .unwrap_or_default();
    let first_subdomain = host_str
        .split('.')
        .next()
        .unwrap_or_default();

    let standard_path = url.path();

    let bucket_object_path = format!("/{}/{}", first_subdomain, standard_path.trim_start_matches('/'));

    bucket_object_path
}

fn canonical_query_string<'a, Q>(query_string: Q, mut out: impl fmt::Write) -> fmt::Result
where
    Q: Iterator<Item = (&'a str, &'a str)>,
{
    let mut first = true;
    for (key, val) in query_string {
        if !first {
            out.write_char('&')?;
        }
        first = false;
        write!(out, "{}={}", percent_encode(key), percent_encode(val))?;
    }


    Ok(())
}

fn canonical_headers<'a, H>(headers: H, mut out: impl fmt::Write) -> fmt::Result
where
    H: Iterator<Item = (&'a str, &'a str)>,
{
    for (key, val) in headers {
        out.write_str(key)?;
        out.write_char(':')?;
        out.write_str(val.trim())?;

        out.write_char('\n')?;
    }

    Ok(())
}

fn canonical_signed_headers<'a, H>(signed_headers: H, mut out: impl fmt::Write) -> fmt::Result
where
    H: Iterator<Item = &'a str>,
{
    let mut first = true;
    for key in signed_headers {
        if first {
            first = false;
        } else {
            out.write_char(';')?;
        }

        out.write_str(key)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

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

    #[test]
    fn oss_example() {
        let method = Method::Get;
        let url = "https://examplebucket.oss-cn-hangzhou.aliyuncs.com"
            .parse()
            .unwrap();

        let expected = concat!(
            "GET\n",
            "/examplebucket/\n",
            "list-type=2&x-oss-additional-headers=host&x-oss-credential=access_key_id%2F20250206%2Fcn-hangzhou%2Foss%2Faliyun_v4_request&x-oss-date=20250206T165151Z&x-oss-expires=86400&x-oss-signature-version=OSS4-HMAC-SHA256\n",
            "host:examplebucket.oss-cn-hangzhou.aliyuncs.com\n",
            "\n",
            "host\n",
            "UNSIGNED-PAYLOAD",
        );

        let got = canonical_request(
            method,
            &url,
            vec![
                ("list-type", "2"),
                ("x-oss-additional-headers", "host"),
                ("x-oss-credential", "access_key_id/20250206/cn-hangzhou/oss/aliyun_v4_request"),
                ("x-oss-date", "20250206T165151Z"),
                ("x-oss-expires", "86400"),
                ("x-oss-signature-version", "OSS4-HMAC-SHA256"),
            ].into_iter(),
            vec![
                ("host", "examplebucket.oss-cn-hangzhou.aliyuncs.com"),
            ].into_iter(),
            vec!["host"].into_iter(),
        );

        assert_eq!(got, expected);
    }
}