r2kit 0.1.0

A safe, ergonomic Rust toolkit for Cloudflare R2 object storage.
Documentation
use base64::{Engine as _, engine::general_purpose::STANDARD};
use proptest::{collection, prelude::*};
use r2kit::{
    CompletionManifest, MultipartPartReceipt, MultipartSessionSnapshot, PartMd5, PartNumber,
    R2Client, R2Config,
};

const MIB: u64 = 1024 * 1024;

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

proptest! {
    #![proptest_config(ProptestConfig {
        cases: 2_048,
        max_shrink_iters: 20_000,
        failure_persistence: None,
        ..ProptestConfig::default()
    })]

    #[test]
    fn multipart_plan_covers_every_byte_exactly(
        part_size_mib in 5_u64..=64,
        part_count in 1_u16..=1_024,
        tail_seed in any::<u64>(),
    ) {
        let part_size = part_size_mib * MIB;
        let tail = tail_seed % part_size + 1;
        let file_size = u64::from(part_count - 1) * part_size + tail;
        let snapshot = MultipartSessionSnapshot::restore(
            "property-bucket",
            "property/object.bin",
            "property-upload-id",
            file_size,
            part_size,
        )
        .unwrap();
        let session = offline_bucket().resume_presigned_multipart(snapshot).unwrap();

        prop_assert_eq!(session.part_count(), part_count);
        let mut covered = 0_u64;
        for raw in 1..=part_count {
            let number = PartNumber::try_from(raw).unwrap();
            let length = session.part_length(number).unwrap();
            prop_assert!(length > 0);
            prop_assert!(length <= part_size);
            if raw < part_count {
                prop_assert_eq!(length, part_size);
            } else {
                prop_assert_eq!(length, tail);
            }
            covered = covered.checked_add(length).unwrap();
        }
        prop_assert_eq!(covered, file_size);
    }

    #[test]
    fn manifest_canonicalization_preserves_every_unique_receipt(
        parts in collection::btree_map(1_u16..=10_000, "[A-Za-z0-9]{1,40}", 1..256),
    ) {
        let mut receipts: Vec<_> = parts
            .iter()
            .rev()
            .map(|(&number, etag)| MultipartPartReceipt::new(number, etag.clone()))
            .collect();
        let manifest = CompletionManifest::try_from_receipts(receipts.drain(..)).unwrap();
        let actual: Vec<_> = manifest
            .parts()
            .map(|part| (part.part_number().get(), part.etag().to_owned()))
            .collect();
        let expected: Vec<_> = parts.into_iter().collect();
        prop_assert_eq!(actual, expected);
    }

    #[test]
    fn duplicate_receipts_are_always_rejected(
        number in 1_u16..=10_000,
        first in "[A-Za-z0-9]{1,40}",
        second in "[A-Za-z0-9]{1,40}",
    ) {
        let result = CompletionManifest::try_from_receipts([
            MultipartPartReceipt::new(number, first),
            MultipartPartReceipt::new(number, second),
        ]);
        prop_assert!(result.is_err());
    }

    #[test]
    fn md5_accepts_exactly_canonical_128_bit_values(bytes in any::<[u8; 16]>()) {
        let encoded = STANDARD.encode(bytes);
        let checksum = PartMd5::try_from(encoded.as_str()).unwrap();
        prop_assert_eq!(checksum.as_base64(), encoded.as_str());

        let unpadded = encoded.trim_end_matches('=');
        prop_assert!(PartMd5::try_from(unpadded).is_err());
    }

    #[test]
    fn snapshot_debug_never_exposes_random_upload_ids(secret in "[A-Za-z0-9_-]{16,96}") {
        let snapshot = MultipartSessionSnapshot::restore(
            "property-bucket",
            "property/object.bin",
            secret.clone(),
            5 * MIB,
            5 * MIB,
        )
        .unwrap();
        let snapshot_debug = format!("{snapshot:?}");
        prop_assert!(!snapshot_debug.contains(&secret));
        let record = snapshot.into_persistence_record();
        let record_debug = format!("{record:?}");
        prop_assert!(!record_debug.contains(&secret));
    }

    #[test]
    fn utf8_key_limit_is_measured_in_bytes(char_count in 257_usize..=400) {
        let key = "🦀".repeat(char_count);
        let result = MultipartSessionSnapshot::restore(
            "property-bucket",
            key,
            "property-upload-id",
            5 * MIB,
            5 * MIB,
        );
        prop_assert!(result.is_err());
    }
}

#[test]
fn multipart_boundaries_are_exact() {
    assert!(PartNumber::try_from(0).is_err());
    assert!(PartNumber::try_from(1).is_ok());
    assert!(PartNumber::try_from(10_000).is_ok());
    assert!(PartNumber::try_from(10_001).is_err());

    assert!(
        MultipartSessionSnapshot::restore(
            "property-bucket",
            "object.bin",
            "upload-id",
            1,
            5 * MIB,
        )
        .is_ok()
    );
    assert!(
        MultipartSessionSnapshot::restore(
            "property-bucket",
            "object.bin",
            "upload-id",
            0,
            5 * MIB,
        )
        .is_err()
    );
}

#[cfg(feature = "serde")]
proptest! {
    #![proptest_config(ProptestConfig {
        cases: 512,
        failure_persistence: None,
        ..ProptestConfig::default()
    })]

    #[test]
    fn persistence_json_round_trips_random_valid_sessions(
        part_size_mib in 5_u64..=64,
        part_count in 1_u16..=1_024,
        tail_seed in any::<u64>(),
        upload_id in "[A-Za-z0-9_-]{16,96}",
    ) {
        let part_size = part_size_mib * MIB;
        let tail = tail_seed % part_size + 1;
        let file_size = u64::from(part_count - 1) * part_size + tail;
        let original = MultipartSessionSnapshot::restore(
            "property-bucket",
            "property/object.bin",
            upload_id,
            file_size,
            part_size,
        )
        .unwrap();
        let json = serde_json::to_vec(&original.into_persistence_record()).unwrap();
        let record = serde_json::from_slice(&json).unwrap();
        let restored = MultipartSessionSnapshot::from_persistence_record(record).unwrap();

        prop_assert_eq!(restored.bucket(), "property-bucket");
        prop_assert_eq!(restored.key(), "property/object.bin");
        prop_assert_eq!(restored.file_size(), file_size);
        prop_assert_eq!(restored.part_size(), part_size);
    }
}