s3-wire 0.3.1

An async, streaming S3-compatible client for Rust
Documentation
use std::collections::BTreeMap;

use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use http::header::{CONTENT_LENGTH, CONTENT_TYPE, ETAG, LAST_MODIFIED};
use http::{HeaderMap, HeaderValue};

use crate::error::S3Error;
use crate::operation::{Checksum, ObjectMetadata, RequestIds};

pub(in crate::client::object) fn parse_object_metadata(
    headers: &HeaderMap,
) -> Result<ObjectMetadata, S3Error> {
    let content_length = headers
        .get(CONTENT_LENGTH)
        .map(parse_u64_value)
        .transpose()?
        .ok_or_else(|| S3Error::invalid_response("S3 omitted the content-length header"))?;
    let last_modified = headers
        .get(LAST_MODIFIED)
        .map(parse_http_date_value)
        .transpose()?;
    let mut user_metadata = BTreeMap::new();
    for (name, value) in headers {
        if let Some(suffix) = name.as_str().strip_prefix("x-amz-meta-") {
            let value = response_header_value(value)?;
            user_metadata.insert(suffix.to_owned(), value);
        }
    }
    Ok(ObjectMetadata {
        e_tag: response_header(headers, ETAG.as_str())?,
        content_length,
        content_type: response_header(headers, CONTENT_TYPE.as_str())?,
        last_modified,
        version_id: response_header(headers, "x-amz-version-id")?,
        user_metadata,
        checksum: parse_checksum(headers)?,
        request_ids: parse_request_ids(headers)?,
    })
}

pub(in crate::client::object) fn parse_checksum(headers: &HeaderMap) -> Result<Checksum, S3Error> {
    Ok(Checksum {
        crc32: response_header(headers, "x-amz-checksum-crc32")?,
        crc32c: response_header(headers, "x-amz-checksum-crc32c")?,
        crc64_nvme: response_header(headers, "x-amz-checksum-crc64nvme")?,
        sha1: response_header(headers, "x-amz-checksum-sha1")?,
        sha256: response_header(headers, "x-amz-checksum-sha256")?,
    })
}

pub(in crate::client::object) fn verified_download_sha256(
    headers: &HeaderMap,
) -> Result<Option<[u8; 32]>, S3Error> {
    let Some(encoded) = response_header(headers, "x-amz-checksum-sha256")? else {
        return Ok(None);
    };
    if response_header(headers, "x-amz-checksum-type")?
        .is_some_and(|checksum_type| checksum_type != "FULL_OBJECT")
    {
        return Ok(None);
    }
    let decoded = BASE64_STANDARD
        .decode(encoded)
        .map_err(|_| S3Error::invalid_response("S3 returned an invalid base64 SHA-256 checksum"))?;
    let digest = decoded.try_into().map_err(|_| {
        S3Error::invalid_response("S3 returned a SHA-256 checksum with an invalid length")
    })?;
    Ok(Some(digest))
}

pub(in crate::client::object) fn merge_checksum(target: &mut Checksum, headers: Checksum) {
    target.crc32 = headers.crc32.or_else(|| target.crc32.take());
    target.crc32c = headers.crc32c.or_else(|| target.crc32c.take());
    target.crc64_nvme = headers.crc64_nvme.or_else(|| target.crc64_nvme.take());
    target.sha1 = headers.sha1.or_else(|| target.sha1.take());
    target.sha256 = headers.sha256.or_else(|| target.sha256.take());
}

pub(in crate::client::object) fn parse_request_ids(
    headers: &HeaderMap,
) -> Result<RequestIds, S3Error> {
    Ok(RequestIds {
        request_id: response_header(headers, "x-amz-request-id")?,
        host_id: response_header(headers, "x-amz-id-2")?,
    })
}

pub(in crate::client::object) fn response_header(
    headers: &HeaderMap,
    name: &str,
) -> Result<Option<String>, S3Error> {
    headers.get(name).map(response_header_value).transpose()
}

fn response_header_value(value: &HeaderValue) -> Result<String, S3Error> {
    value
        .to_str()
        .map(str::to_owned)
        .map_err(|_| S3Error::invalid_response("S3 returned a non-text response header"))
}

pub(in crate::client::object) fn parse_u64_value(value: &HeaderValue) -> Result<u64, S3Error> {
    value
        .to_str()
        .ok()
        .and_then(|value| value.parse().ok())
        .ok_or_else(|| S3Error::invalid_response("S3 returned an invalid content length"))
}

fn parse_http_date_value(value: &HeaderValue) -> Result<time::OffsetDateTime, S3Error> {
    let value = value
        .to_str()
        .map_err(|_| S3Error::invalid_response("S3 returned an invalid HTTP date"))?;
    let parsed = httpdate::parse_http_date(value)
        .map_err(|_| S3Error::invalid_response("S3 returned an invalid HTTP date"))?;
    Ok(time::OffsetDateTime::from(parsed))
}

pub(in crate::client::object) fn parse_bool_header(
    headers: &HeaderMap,
    name: &str,
) -> Result<Option<bool>, S3Error> {
    let Some(value) = response_header(headers, name)? else {
        return Ok(None);
    };
    match value.as_str() {
        "true" => Ok(Some(true)),
        "false" => Ok(Some(false)),
        _ => Err(S3Error::invalid_response(
            "S3 returned an invalid boolean response header",
        )),
    }
}

pub(in crate::client::object) fn parse_content_range(
    headers: &HeaderMap,
) -> Result<Option<(u64, u64, Option<u64>)>, S3Error> {
    let Some(value) = response_header(headers, "content-range")? else {
        return Ok(None);
    };
    let remainder = value
        .strip_prefix("bytes ")
        .ok_or_else(|| S3Error::invalid_response("S3 returned an invalid content-range header"))?;
    let (range, complete) = remainder
        .split_once('/')
        .ok_or_else(|| S3Error::invalid_response("S3 returned an invalid content-range header"))?;
    let (start, end) = range
        .split_once('-')
        .ok_or_else(|| S3Error::invalid_response("S3 returned an invalid content-range header"))?;
    let start = start
        .parse::<u64>()
        .map_err(|_| S3Error::invalid_response("S3 returned an invalid content-range header"))?;
    let end = end
        .parse::<u64>()
        .map_err(|_| S3Error::invalid_response("S3 returned an invalid content-range header"))?;
    if end < start {
        return Err(S3Error::invalid_response(
            "S3 returned an invalid content-range header",
        ));
    }
    let complete = if complete == "*" {
        None
    } else {
        let complete = complete.parse::<u64>().map_err(|_| {
            S3Error::invalid_response("S3 returned an invalid content-range header")
        })?;
        if end >= complete {
            return Err(S3Error::invalid_response(
                "S3 returned an inconsistent content-range header",
            ));
        }
        Some(complete)
    };
    Ok(Some((start, end, complete)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::ErrorCategory;
    use sha2::{Digest as _, Sha256};
    use time::macros::datetime;

    #[test]
    fn parses_complete_metadata_headers() {
        let mut headers = HeaderMap::new();
        headers.insert(CONTENT_LENGTH, HeaderValue::from_static("7"));
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
        headers.insert(ETAG, HeaderValue::from_static("\"tag\""));
        headers.insert(
            LAST_MODIFIED,
            HeaderValue::from_static("Tue, 12 Mar 2024 10:15:30 GMT"),
        );
        headers.insert("x-amz-version-id", HeaderValue::from_static("version"));
        headers.insert("x-amz-meta-owner", HeaderValue::from_static("runtrue"));
        headers.insert("x-amz-checksum-sha256", HeaderValue::from_static("sum="));
        headers.insert("x-amz-request-id", HeaderValue::from_static("request"));
        headers.insert("x-amz-id-2", HeaderValue::from_static("host"));

        let parsed = parse_object_metadata(&headers).unwrap();
        assert_eq!(parsed.content_length, 7);
        assert_eq!(parsed.content_type.as_deref(), Some("text/plain"));
        assert_eq!(parsed.e_tag.as_deref(), Some("\"tag\""));
        assert_eq!(parsed.user_metadata["owner"], "runtrue");
        assert_eq!(parsed.checksum.sha256.as_deref(), Some("sum="));
        assert_eq!(parsed.request_ids.request_id.as_deref(), Some("request"));
        assert_eq!(
            parsed.last_modified,
            Some(datetime!(2024-03-12 10:15:30 UTC))
        );
    }

    #[test]
    fn validates_only_full_object_sha256_checksums() {
        let mut headers = HeaderMap::new();
        headers.insert(
            "x-amz-checksum-sha256",
            HeaderValue::from_static("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="),
        );
        assert_eq!(
            verified_download_sha256(&headers).unwrap(),
            Some(Sha256::digest(b"abc").into())
        );

        headers.insert("x-amz-checksum-type", HeaderValue::from_static("COMPOSITE"));
        assert_eq!(verified_download_sha256(&headers).unwrap(), None);

        headers.insert(
            "x-amz-checksum-type",
            HeaderValue::from_static("FULL_OBJECT"),
        );
        headers.insert(
            "x-amz-checksum-sha256",
            HeaderValue::from_static("not-base64"),
        );
        assert_eq!(
            verified_download_sha256(&headers).unwrap_err().category(),
            ErrorCategory::InvalidResponse
        );
        headers.insert("x-amz-checksum-sha256", HeaderValue::from_static("YQ=="));
        assert_eq!(
            verified_download_sha256(&headers).unwrap_err().category(),
            ErrorCategory::InvalidResponse
        );
    }

    #[test]
    fn content_ranges_are_strictly_validated() {
        let mut headers = HeaderMap::new();
        headers.insert("content-range", HeaderValue::from_static("bytes 2-8/10"));
        assert_eq!(
            parse_content_range(&headers).unwrap(),
            Some((2, 8, Some(10)))
        );
        headers.insert("content-range", HeaderValue::from_static("bytes 8-2/10"));
        assert!(parse_content_range(&headers).is_err());
        headers.insert("content-range", HeaderValue::from_static("bytes 2-10/10"));
        assert!(parse_content_range(&headers).is_err());
    }
}