r2kit 0.1.0

A safe, ergonomic Rust toolkit for Cloudflare R2 object storage.
Documentation
use std::time::{Duration, UNIX_EPOCH};

use r2kit::{
    CacheControl, Error, MultipartSessionSnapshot, ObjectUploadOptions, PartMd5, PartNumber,
    R2Client, R2Config, ValidationError, mime,
};

fn offline_bucket() -> r2kit::Bucket {
    let config = R2Config::builder()
        .account_id("0123456789abcdef0123456789abcdef")
        .access_key_id("contract-access-key")
        .secret_access_key("contract-secret-key")
        .session_token("contract-session-token")
        .build()
        .unwrap();
    R2Client::new(config).bucket("r2kit").unwrap()
}

#[tokio::test]
async fn presigns_upload_part_without_exposing_secrets_in_debug() {
    let config = R2Config::builder()
        .account_id("0123456789abcdef0123456789abcdef")
        .access_key_id("contract-access-key")
        .secret_access_key("contract-secret-key")
        .session_token("contract-session-token")
        .build()
        .unwrap();
    let client = R2Client::new(config);
    let client_debug = format!("{client:?}");
    assert!(!client_debug.contains("contract-access-key"));
    assert!(!client_debug.contains("contract-secret-key"));
    assert!(!client_debug.contains("contract-session-token"));
    let bucket = client.bucket("r2kit").unwrap();
    let snapshot = MultipartSessionSnapshot::restore(
        "r2kit",
        "_r2kit-tests/offline/file.bin",
        "offline-upload-id",
        11 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();
    let session = bucket.resume_presigned_multipart(snapshot).unwrap();
    let part = session
        .presign_part(PartNumber::try_from(2).unwrap(), Duration::from_secs(900))
        .await
        .unwrap();

    assert_eq!(part.request().method(), "PUT");
    assert_eq!(part.content_length(), 5 * 1024 * 1024);
    let exposed = part.request().url().expose();
    assert!(exposed.contains("partNumber=2"));
    assert!(exposed.contains("uploadId=offline-upload-id"));
    assert!(exposed.contains("X-Amz-Signature="));

    let debug = format!("{part:?}");
    assert!(!debug.contains("X-Amz-Signature"));
    assert!(!debug.contains("contract-access-key"));
    assert!(!debug.contains("contract-session-token"));
}

#[tokio::test]
async fn rejects_invalid_presign_expiry_before_network() {
    let config = R2Config::builder()
        .account_id("0123456789abcdef0123456789abcdef")
        .access_key_id("access")
        .secret_access_key("secret")
        .build()
        .unwrap();
    let bucket = R2Client::new(config).bucket("r2kit").unwrap();
    let snapshot = MultipartSessionSnapshot::restore(
        "r2kit",
        "key",
        "upload-id",
        5 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();
    let session = bucket.resume_presigned_multipart(snapshot).unwrap();
    let part = PartNumber::try_from(1).unwrap();

    assert!(matches!(
        session.presign_part(part, Duration::ZERO).await,
        Err(Error::Validation(
            ValidationError::PresignExpiryOutOfRange {
                provided: Duration::ZERO,
                min,
                max
            }
        )) if min == Duration::from_secs(1) && max == Duration::from_secs(604_800)
    ));
    let too_long = Duration::from_secs(604_801);
    assert!(matches!(
        session.presign_part(part, too_long).await,
        Err(Error::Validation(
            ValidationError::PresignExpiryOutOfRange { provided, .. }
        )) if provided == too_long
    ));
}

#[tokio::test]
async fn checksum_presign_requires_content_md5_and_exposes_a_redacted_protocol_dto() {
    let bucket = offline_bucket();
    let snapshot = MultipartSessionSnapshot::restore(
        "r2kit",
        "objects/checksummed.bin",
        "checksum-upload-id",
        5 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();
    let session = bucket.resume_presigned_multipart(snapshot).unwrap();
    let md5 = PartMd5::try_from("AAAAAAAAAAAAAAAAAAAAAA==").unwrap();
    let part = session
        .presign_part_with_md5(
            PartNumber::try_from(1).unwrap(),
            md5,
            Duration::from_secs(900),
        )
        .await
        .unwrap();

    assert_eq!(
        part.content_md5().map(PartMd5::as_base64),
        Some("AAAAAAAAAAAAAAAAAAAAAA==")
    );
    assert!(part.request().required_headers().any(|(name, value)| {
        name.eq_ignore_ascii_case("content-md5") && value == "AAAAAAAAAAAAAAAAAAAAAA=="
    }));

    let protocol = part.into_protocol_request().unwrap();
    assert_eq!(protocol.part_number(), 1);
    assert_eq!(protocol.content_length(), 5 * 1024 * 1024);
    assert_eq!(protocol.content_md5(), Some("AAAAAAAAAAAAAAAAAAAAAA=="));
    assert!(protocol.expose_url().contains("X-Amz-Signature="));
    assert!(!format!("{protocol:?}").contains("X-Amz-Signature"));
}

#[test]
fn rejects_noncanonical_or_wrong_length_md5() {
    assert!(PartMd5::try_from("not-base64").is_err());
    assert!(PartMd5::try_from("YWJj").is_err());
    assert!(PartMd5::try_from("AAAAAAAAAAAAAAAAAAAAAA").is_err());
}

#[tokio::test]
async fn presigns_single_get_and_put_with_redacted_bearer_urls() {
    let bucket = offline_bucket();
    let get = bucket
        .presign_get("objects/file.bin", Duration::from_secs(900))
        .await
        .unwrap();
    assert_eq!(get.method(), "GET");
    assert!(get.url().expose().contains("X-Amz-Signature="));
    assert!(!format!("{get:?}").contains("X-Amz-Signature"));

    let put = bucket
        .presign_put("objects/file.bin", 42, Duration::from_secs(900))
        .await
        .unwrap();
    assert_eq!(put.content_length(), 42);
    assert_eq!(put.request().method(), "PUT");
    assert!(put.request().url().expose().contains("X-Amz-Signature="));
    assert!(!format!("{put:?}").contains("X-Amz-Signature"));
}

#[tokio::test]
async fn presigned_put_signs_typed_object_metadata_as_required_headers() {
    let bucket = offline_bucket();
    let options = ObjectUploadOptions::builder()
        .content_type(mime::IMAGE_JPEG)
        .cache_control(
            CacheControl::new()
                .with_public()
                .with_max_age(Duration::from_secs(3_600)),
        )
        .build();
    let put = bucket
        .presign_put_with_options("photos/cat.jpg", 42, Duration::from_secs(900), options)
        .await
        .unwrap();
    let headers: Vec<_> = put.request().required_headers().collect();

    assert!(headers.iter().any(|(name, value)| {
        name.eq_ignore_ascii_case("content-type") && *value == "image/jpeg"
    }));
    assert!(headers.iter().any(|(name, value)| {
        name.eq_ignore_ascii_case("cache-control")
            && value.contains("public")
            && value.contains("max-age=3600")
    }));
    let url = put.request().url().expose();
    assert!(url.contains("X-Amz-SignedHeaders="));
    assert!(!format!("{put:?}").contains("X-Amz-Signature"));
}

#[tokio::test]
async fn presigned_put_signs_extended_headers_and_custom_metadata() {
    let bucket = offline_bucket();
    let expires = UNIX_EPOCH + Duration::from_secs(1_893_456_000);
    let options = ObjectUploadOptions::builder()
        .content_disposition("attachment; filename=report.csv")
        .content_encoding("gzip")
        .content_language("en-US")
        .expires(expires)
        .custom_metadata("tenant-id", "tenant-42")
        .build();
    let put = bucket
        .presign_put_with_options(
            "reports/report.csv.gz",
            42,
            Duration::from_secs(900),
            options,
        )
        .await
        .unwrap();
    let headers: Vec<_> = put.request().required_headers().collect();

    for (name, expected) in [
        ("content-disposition", "attachment; filename=report.csv"),
        ("content-encoding", "gzip"),
        ("content-language", "en-US"),
        ("x-amz-meta-tenant-id", "tenant-42"),
    ] {
        assert!(
            headers
                .iter()
                .any(|(actual, value)| actual.eq_ignore_ascii_case(name) && *value == expected),
            "missing signed header {name}"
        );
    }
    assert!(
        headers
            .iter()
            .any(|(name, _)| name.eq_ignore_ascii_case("expires"))
    );
}

#[tokio::test]
async fn rejects_invalid_upload_metadata_before_signing() {
    let bucket = offline_bucket();
    let duplicate = ObjectUploadOptions::builder()
        .custom_metadata("Tenant", "one")
        .custom_metadata("tenant", "two")
        .build();
    let error = bucket
        .presign_put_with_options("key", 1, Duration::from_secs(60), duplicate)
        .await
        .unwrap_err();
    assert!(matches!(
        error,
        Error::InvalidInput {
            field: "custom_metadata",
            ..
        }
    ));

    let prefixed = ObjectUploadOptions::builder()
        .custom_metadata("x-amz-meta-tenant", "one")
        .build();
    let error = bucket
        .presign_put_with_options("key", 1, Duration::from_secs(60), prefixed)
        .await
        .unwrap_err();
    assert!(matches!(
        error,
        Error::InvalidInput {
            field: "custom_metadata",
            ..
        }
    ));
}

#[tokio::test]
async fn rejects_invalid_single_object_presign_contracts() {
    let bucket = offline_bucket();

    assert!(matches!(
        bucket.presign_get("key", Duration::from_millis(999)).await,
        Err(Error::Validation(
            ValidationError::PresignExpiryOutOfRange { .. }
        ))
    ));
    assert!(matches!(
        bucket
            .presign_put(
                "key",
                5 * 1024 * 1024 * 1024 - 5 * 1024 * 1024 + 1,
                Duration::from_secs(60),
            )
            .await,
        Err(Error::Validation(ValidationError::SingleUploadTooLarge {
            provided: 5_363_466_241,
            max: 5_363_466_240
        }))
    ));
}