r2kit 0.1.0

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::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::ManagedUploadCancellation>();
    assert_send_sync::<r2kit::PresignedMultipart>();
}

#[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 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());
}