r2kit 0.2.1

A safe, ergonomic Rust toolkit for Cloudflare R2 object storage.
Documentation
use r2kit::{
    CompletionManifest, Error, MultipartPartReceipt, MultipartSessionSnapshot, PartNumber,
    ValidationError,
};

fn assert_send_sync<T: Send + Sync>() {}

#[test]
fn async_handles_are_send_and_sync() {
    assert_send_sync::<r2kit::ObjectKey>();
    assert_send_sync::<r2kit::BucketName>();
    assert_send_sync::<r2kit::R2Config>();
    assert_send_sync::<r2kit::R2ConfigBuilder>();
    assert_send_sync::<r2kit::R2Jurisdiction>();
    assert_send_sync::<r2kit::R2Client>();
    assert_send_sync::<r2kit::Bucket>();
    assert_send_sync::<r2kit::BucketInfo>();
    assert_send_sync::<r2kit::ManagedMultipartBuilder>();
    assert_send_sync::<r2kit::ManagedUploadCancellation>();
    assert_send_sync::<r2kit::ManagedUploadError>();
    assert_send_sync::<r2kit::ManagedUploadProgress>();
    assert_send_sync::<r2kit::ManagedUploadResult>();
    assert_send_sync::<r2kit::PresignedMultipart>();
    assert_send_sync::<r2kit::PresignedMultipartBuilder>();
    assert_send_sync::<r2kit::CompletedObject>();
    assert_send_sync::<r2kit::CompletionManifest>();
    assert_send_sync::<r2kit::MultipartPartReceipt>();
    assert_send_sync::<r2kit::MultipartSessionRecord>();
    assert_send_sync::<r2kit::MultipartSessionSnapshot>();
    assert_send_sync::<r2kit::PartNumber>();
    assert_send_sync::<r2kit::ByteRange>();
    assert_send_sync::<r2kit::ChecksumAlgorithm>();
    assert_send_sync::<r2kit::MetadataDirective>();
    assert_send_sync::<r2kit::GetObjectBuilder>();
    assert_send_sync::<r2kit::HeadObjectBuilder>();
    assert_send_sync::<r2kit::CopyObjectBuilder>();
    assert_send_sync::<r2kit::CopyObjectResult>();
    assert_send_sync::<r2kit::ListObjectsBuilder>();
    assert_send_sync::<r2kit::ObjectUploadOptions>();
    assert_send_sync::<r2kit::ObjectBytes>();
    assert_send_sync::<r2kit::PutObjectResult>();
    assert_send_sync::<r2kit::PresignedPutObject>();
    assert_send_sync::<r2kit::DownloadedObject>();
    assert_send_sync::<r2kit::ObjectMetadata>();
    assert_send_sync::<r2kit::ObjectPage>();
    assert_send_sync::<r2kit::ObjectSummary>();
    assert_send_sync::<r2kit::DeleteObjectsResult>();
    assert_send_sync::<r2kit::BatchDeleteError>();
    assert_send_sync::<r2kit::DeleteObjectFailure>();
    assert_send_sync::<r2kit::ListMultipartUploadsBuilder>();
    assert_send_sync::<r2kit::MultipartUploadPage>();
    assert_send_sync::<r2kit::MultipartUploadSummary>();
    assert_send_sync::<r2kit::UploadThreshold>();
    assert_send_sync::<r2kit::PresignedUploadPlan>();
    assert_send_sync::<r2kit::PresignedMultipartPlan>();
}

#[test]
fn multipart_snapshot_exposes_every_persistence_field_deliberately() {
    let snapshot = MultipartSessionSnapshot::restore(
        "example-bucket",
        "videos/example.mp4",
        "sensitive-upload-id",
        11 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();

    assert_eq!(snapshot.bucket(), "example-bucket");
    assert_eq!(snapshot.key(), "videos/example.mp4");
    assert_eq!(snapshot.expose_upload_id(), "sensitive-upload-id");
    assert_eq!(snapshot.file_size(), 11 * 1024 * 1024);
    assert_eq!(snapshot.part_size(), 5 * 1024 * 1024);

    let debug = format!("{snapshot:?}");
    assert!(!debug.contains("sensitive-upload-id"));
}

#[test]
fn multipart_snapshot_restore_validates_bucket_name() {
    let result = MultipartSessionSnapshot::restore(
        "INVALID.BUCKET.NAME",
        "videos/example.mp4",
        "sensitive-upload-id",
        11 * 1024 * 1024,
        5 * 1024 * 1024,
    );
    assert!(result.is_err());
}

#[test]
fn persistence_record_round_trips_through_validation() {
    let snapshot = MultipartSessionSnapshot::restore(
        "example-bucket",
        "videos/example.mp4",
        "sensitive-upload-id",
        11 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();
    let record = snapshot.into_persistence_record();

    assert_eq!(record.version(), 1);
    assert_eq!(record.expose_upload_id(), "sensitive-upload-id");
    assert!(!format!("{record:?}").contains("sensitive-upload-id"));

    let restored = MultipartSessionSnapshot::from_persistence_record(record).unwrap();
    assert_eq!(restored.key(), "videos/example.mp4");
}

#[test]
fn uploader_receipt_is_validated_at_the_trust_boundary() {
    let receipt = MultipartPartReceipt::new(1, "\"etag-from-r2\"");
    let uploaded = receipt.try_into_uploaded_part().unwrap();
    assert_eq!(uploaded.part_number().get(), 1);
    assert_eq!(uploaded.etag(), "\"etag-from-r2\"");

    assert!(
        MultipartPartReceipt::new(0, "etag")
            .try_into_uploaded_part()
            .is_err()
    );
    assert!(
        MultipartPartReceipt::new(1, "")
            .try_into_uploaded_part()
            .is_err()
    );

    let manifest = CompletionManifest::try_from_receipts([
        MultipartPartReceipt::new(2, "etag-2"),
        MultipartPartReceipt::new(1, "etag-1"),
    ])
    .unwrap();
    assert_eq!(
        manifest
            .parts()
            .map(|part| part.part_number().get())
            .collect::<Vec<_>>(),
        [1, 2]
    );
}

#[test]
fn part_number_errors_expose_the_exact_r2_bounds() {
    for provided in [0, 10_001] {
        assert!(matches!(
            PartNumber::try_from(provided),
            Err(Error::Validation(ValidationError::PartNumberOutOfRange {
                provided: actual,
                min: 1,
                max: 10_000
            })) if actual == provided
        ));
    }
    assert_eq!(PartNumber::try_from(1).unwrap().get(), 1);
    assert_eq!(PartNumber::try_from(10_000).unwrap().get(), 10_000);
}

#[cfg(feature = "serde")]
#[test]
fn persistence_record_and_receipt_support_serde_without_leaking_in_debug() {
    let snapshot = MultipartSessionSnapshot::restore(
        "example-bucket",
        "videos/example.mp4",
        "sensitive-upload-id",
        5 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();
    let json = serde_json::to_string(&snapshot.into_persistence_record()).unwrap();
    let record = serde_json::from_str(&json).unwrap();
    let restored = MultipartSessionSnapshot::from_persistence_record(record).unwrap();
    assert_eq!(restored.expose_upload_id(), "sensitive-upload-id");

    let receipt = MultipartPartReceipt::new(1, "etag");
    let json = serde_json::to_string(&receipt).unwrap();
    let decoded: MultipartPartReceipt = serde_json::from_str(&json).unwrap();
    assert_eq!(decoded, receipt);

    let snapshot = MultipartSessionSnapshot::restore(
        "example-bucket",
        "videos/example.mp4",
        "sensitive-upload-id",
        5 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();
    let mut unsupported = serde_json::to_value(snapshot.into_persistence_record()).unwrap();
    unsupported["version"] = serde_json::json!(2);
    let record = serde_json::from_value(unsupported).unwrap();
    assert!(MultipartSessionSnapshot::from_persistence_record(record).is_err());
}

#[test]
fn list_multipart_uploads_builder_debug_redacts_upload_id_marker() {
    let config = r2kit::R2Config::builder()
        .account_id("0123456789abcdef0123456789abcdef")
        .access_key_id("test")
        .secret_access_key("test")
        .build()
        .unwrap();
    let bucket = r2kit::R2Client::new(config).bucket("test-bucket").unwrap();
    let builder = bucket
        .list_multipart_uploads()
        .upload_id_marker("sensitive-upload-id-marker-98765");
    let debug = format!("{builder:?}");
    assert!(
        !debug.contains("sensitive-upload-id-marker-98765"),
        "upload_id_marker was leaked in Debug output: {debug}"
    );
}

#[test]
fn into_content_type_converts_str_string_and_mime() {
    use r2kit::IntoContentType;

    let mime_from_str = "application/json".into_content_type().unwrap();
    assert_eq!(mime_from_str, mime::APPLICATION_JSON);

    let mime_from_string = String::from("text/plain").into_content_type().unwrap();
    assert_eq!(mime_from_string, mime::TEXT_PLAIN);

    let mime_from_mime = mime::APPLICATION_OCTET_STREAM.into_content_type().unwrap();
    assert_eq!(mime_from_mime, mime::APPLICATION_OCTET_STREAM);

    let invalid = "not a valid mime type".into_content_type();
    assert!(matches!(
        invalid,
        Err(Error::InvalidInput {
            field: "content_type",
            ..
        })
    ));
}

#[cfg(feature = "serde")]
#[test]
fn multipart_session_snapshot_supports_direct_serde() {
    let snapshot = MultipartSessionSnapshot::restore(
        "example-bucket",
        "videos/example.mp4",
        "sensitive-upload-id-123",
        11 * 1024 * 1024,
        5 * 1024 * 1024,
    )
    .unwrap();

    let json = serde_json::to_string(&snapshot).unwrap();
    assert!(json.contains("example-bucket"));
    assert!(json.contains("videos/example.mp4"));
    assert!(json.contains("sensitive-upload-id-123"));

    let restored: MultipartSessionSnapshot = serde_json::from_str(&json).unwrap();
    assert_eq!(restored.bucket(), snapshot.bucket());
    assert_eq!(restored.key(), snapshot.key());
    assert_eq!(restored.expose_upload_id(), snapshot.expose_upload_id());
    assert_eq!(restored.file_size(), snapshot.file_size());
    assert_eq!(restored.part_size(), snapshot.part_size());

    // Invalid bucket name fails deserialization at trust boundary
    let invalid_bucket = json.replace("example-bucket", "INVALID BUCKET");
    assert!(serde_json::from_str::<MultipartSessionSnapshot>(&invalid_bucket).is_err());

    // Invalid part size (< 5 MiB) fails deserialization
    let invalid_part = json.replace(&snapshot.part_size().to_string(), "1024");
    assert!(serde_json::from_str::<MultipartSessionSnapshot>(&invalid_part).is_err());

    // Empty upload ID fails deserialization
    let empty_upload_id = json.replace("sensitive-upload-id-123", "");
    assert!(serde_json::from_str::<MultipartSessionSnapshot>(&empty_upload_id).is_err());
}

#[cfg(feature = "serde")]
#[test]
fn upload_threshold_supports_direct_serde() {
    let threshold = r2kit::UploadThreshold::default();
    let json = serde_json::to_string(&threshold).unwrap();
    assert_eq!(json, (8 * 1024 * 1024).to_string());

    let restored: r2kit::UploadThreshold = serde_json::from_str(&json).unwrap();
    assert_eq!(restored, threshold);

    let invalid: Result<r2kit::UploadThreshold, _> = serde_json::from_str("1024");
    assert!(invalid.is_err());
}