use aws_sdk_s3::{config::Credentials, primitives::ByteStream};
use r2kit::{ChecksumAlgorithm, Error, ObjectUploadOptions, R2Client, R2Config, ValidationError};
fn offline_client() -> R2Client {
let config = R2Config::builder()
.account_id("0123456789abcdef0123456789abcdef")
.access_key_id("offline-access")
.secret_access_key("offline-secret")
.build()
.unwrap();
let sdk_config = aws_sdk_s3::Config::builder()
.behavior_version_latest()
.endpoint_url(config.endpoint_url())
.region(aws_sdk_s3::config::Region::new("auto"))
.credentials_provider(Credentials::new(
"offline-access",
"offline-secret",
None,
None,
"contract-test",
))
.build();
R2Client::from_sdk(aws_sdk_s3::Client::from_conf(sdk_config))
}
fn offline_bucket() -> r2kit::Bucket {
offline_client().bucket("contract-tests").unwrap()
}
#[test]
fn enforces_the_documented_r2_bucket_name_length() {
let config = R2Config::builder()
.account_id("0123456789abcdef0123456789abcdef")
.access_key_id("offline-access")
.secret_access_key("offline-secret")
.build()
.unwrap();
let client = R2Client::new(config);
assert!(client.bucket("a".repeat(63)).is_ok());
assert!(matches!(
client.bucket("a".repeat(64)),
Err(Error::InvalidInput {
field: "bucket",
..
})
));
}
#[tokio::test]
async fn rejects_invalid_list_options_before_network() {
let bucket = offline_bucket();
let zero = bucket.list().limit(0).send().await.unwrap_err();
assert!(matches!(
zero,
Error::Validation(ValidationError::ListLimitOutOfRange {
provided: 0,
min: 1,
max: 1_000
})
));
let too_large = bucket.list().limit(1_001).send().await.unwrap_err();
assert!(matches!(
too_large,
Error::Validation(ValidationError::ListLimitOutOfRange {
provided: 1_001,
min: 1,
max: 1_000
})
));
let empty_token = bucket
.list()
.continuation_token("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_token,
Error::InvalidInput {
field: "continuation_token",
..
}
));
}
#[tokio::test]
async fn rejects_invalid_object_writes_before_network() {
let bucket = offline_bucket();
let empty_key = bucket.put_bytes("", Vec::new()).await.unwrap_err();
assert!(matches!(
empty_key,
Error::InvalidInput { field: "key", .. }
));
let too_large = bucket
.put_stream(
"large.bin",
ByteStream::from_static(&[]),
5 * 1024 * 1024 * 1024 - 5 * 1024 * 1024 + 1,
)
.await
.unwrap_err();
assert!(matches!(
too_large,
Error::Validation(ValidationError::SingleUploadTooLarge {
provided: 5_363_466_241,
max: 5_363_466_240
})
));
}
#[tokio::test]
async fn copy_validates_both_keys_before_network() {
let bucket = offline_bucket();
let invalid_source = bucket.copy("", "destination").await.unwrap_err();
assert!(matches!(
invalid_source,
Error::InvalidInput { field: "key", .. }
));
let invalid_destination = bucket.copy("source", "").await.unwrap_err();
assert!(matches!(
invalid_destination,
Error::InvalidInput { field: "key", .. }
));
}
#[tokio::test]
async fn empty_batch_delete_is_a_no_op() {
let result = offline_bucket()
.delete_objects(Vec::<String>::new())
.await
.unwrap();
assert!(result.is_complete());
assert_eq!(result.request_count(), 0);
assert!(result.deleted_keys().is_empty());
assert!(result.failures().is_empty());
}
#[tokio::test]
async fn batch_delete_validates_every_key_before_network() {
let error = offline_bucket()
.delete_objects(["valid", ""])
.await
.unwrap_err();
assert!(matches!(
error.error(),
Error::InvalidInput { field: "key", .. }
));
assert_eq!(error.partial_result().request_count(), 0);
assert!(error.partial_result().deleted_keys().is_empty());
}
#[test]
fn listing_exposes_a_sendable_page_stream() {
fn assert_send<T: Send>(_: &T) {}
let pages = offline_bucket().list().prefix("logs/").into_pages();
assert_send(&pages);
}
#[test]
fn byte_range_formatting_and_validation() {
use r2kit::ByteRange;
assert_eq!(
ByteRange::Bounded(0, 1023).as_header_value(),
"bytes=0-1023"
);
assert_eq!(ByteRange::From(500).as_header_value(), "bytes=500-");
assert_eq!(ByteRange::Suffix(500).as_header_value(), "bytes=-500");
let _invalid_bounds = offline_bucket()
.get_object("key.bin")
.range(ByteRange::Bounded(100, 50));
assert_eq!(ByteRange::Bounded(0, 10).as_header_value(), "bytes=0-10");
}
#[tokio::test]
async fn get_object_validates_inputs_before_network() {
use r2kit::ByteRange;
let bucket = offline_bucket();
let invalid_key = bucket.get_object("").send().await.unwrap_err();
assert!(matches!(
invalid_key,
Error::InvalidInput { field: "key", .. }
));
let invalid_range = bucket
.get_object("valid.bin")
.range(ByteRange::Bounded(100, 50))
.send()
.await
.unwrap_err();
assert!(matches!(
invalid_range,
Error::InvalidInput { field: "range", .. }
));
let invalid_suffix = bucket
.get_object("valid.bin")
.range(ByteRange::Suffix(0))
.send()
.await
.unwrap_err();
assert!(matches!(
invalid_suffix,
Error::InvalidInput { field: "range", .. }
));
let empty_if_match = bucket
.get_object("valid.bin")
.if_match("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_if_match,
Error::InvalidInput {
field: "if_match",
..
}
));
let empty_if_none_match = bucket
.get_object("valid.bin")
.if_none_match("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_if_none_match,
Error::InvalidInput {
field: "if_none_match",
..
}
));
}
#[tokio::test]
async fn head_object_validates_inputs_before_network() {
let bucket = offline_bucket();
let invalid_key = bucket.head_object("").send().await.unwrap_err();
assert!(matches!(
invalid_key,
Error::InvalidInput { field: "key", .. }
));
let empty_if_match = bucket
.head_object("valid.bin")
.if_match("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_if_match,
Error::InvalidInput {
field: "if_match",
..
}
));
let empty_if_none_match = bucket
.head_object("valid.bin")
.if_none_match("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_if_none_match,
Error::InvalidInput {
field: "if_none_match",
..
}
));
}
#[tokio::test]
async fn copy_object_validates_options_before_network() {
let bucket = offline_bucket();
let invalid_source_bucket = bucket
.copy_object("src.jpg", "dst.jpg")
.source_bucket("in")
.send()
.await
.unwrap_err();
assert!(matches!(
invalid_source_bucket,
Error::InvalidInput {
field: "bucket",
..
}
));
let empty_if_match = bucket
.copy_object("src.jpg", "dst.jpg")
.source_if_match("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_if_match,
Error::InvalidInput {
field: "source_if_match",
..
}
));
let empty_if_none_match = bucket
.copy_object("src.jpg", "dst.jpg")
.source_if_none_match("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_if_none_match,
Error::InvalidInput {
field: "source_if_none_match",
..
}
));
}
#[tokio::test]
async fn list_multipart_uploads_validates_options_before_network() {
let bucket = offline_bucket();
let zero = bucket
.list_multipart_uploads()
.limit(0)
.send()
.await
.unwrap_err();
assert!(matches!(
zero,
Error::Validation(ValidationError::ListLimitOutOfRange {
provided: 0,
min: 1,
max: 1_000
})
));
let too_large = bucket
.list_multipart_uploads()
.limit(1_001)
.send()
.await
.unwrap_err();
assert!(matches!(
too_large,
Error::Validation(ValidationError::ListLimitOutOfRange {
provided: 1_001,
min: 1,
max: 1_000
})
));
let empty_delimiter = bucket
.list_multipart_uploads()
.delimiter("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_delimiter,
Error::InvalidInput {
field: "delimiter",
..
}
));
let empty_key_marker = bucket
.list_multipart_uploads()
.key_marker("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_key_marker,
Error::InvalidInput {
field: "key_marker",
..
}
));
let empty_upload_id_marker = bucket
.list_multipart_uploads()
.upload_id_marker("")
.send()
.await
.unwrap_err();
assert!(matches!(
empty_upload_id_marker,
Error::InvalidInput {
field: "upload_id_marker",
..
}
));
}
#[test]
fn list_multipart_uploads_exposes_a_sendable_page_stream() {
fn assert_send<T: Send>(_: &T) {}
let pages = offline_bucket()
.list_multipart_uploads()
.prefix("uploads/")
.into_pages();
assert_send(&pages);
}
#[tokio::test]
async fn bucket_management_validates_bucket_name_before_network() {
let client = offline_client();
assert!(matches!(
client.create_bucket("ab").await.unwrap_err(),
Error::InvalidInput {
field: "bucket",
..
}
));
assert!(matches!(
client.delete_bucket("-invalid").await.unwrap_err(),
Error::InvalidInput {
field: "bucket",
..
}
));
assert!(matches!(
client.bucket_exists("INVALID_NAME").await.unwrap_err(),
Error::InvalidInput {
field: "bucket",
..
}
));
}
#[tokio::test]
async fn download_file_validates_key_before_network() {
let bucket = offline_bucket();
let error = bucket.download_file("", "/tmp/test.tmp").await.unwrap_err();
assert!(matches!(error, Error::InvalidInput { field: "key", .. }));
}
#[test]
fn upload_options_validates_checksum_inputs() {
use r2kit::{ChecksumAlgorithm, ObjectUploadOptions};
let options =
ObjectUploadOptions::new().with_checksum_value(ChecksumAlgorithm::Crc32, "AAAAAA==");
assert!(!options.is_empty());
let options =
ObjectUploadOptions::new().with_checksum_value(ChecksumAlgorithm::Crc32, "not-base-64!!");
let bucket = offline_bucket();
let result = tokio::runtime::Runtime::new().unwrap().block_on(async {
bucket
.put_stream_with_options("key", ByteStream::from_static(&[]), 0, options)
.await
});
assert!(matches!(
result.unwrap_err(),
Error::InvalidInput {
field: "checksum",
..
}
));
let options = ObjectUploadOptions::new().with_checksum_value(ChecksumAlgorithm::Crc32, "AQ==");
let result = tokio::runtime::Runtime::new().unwrap().block_on(async {
bucket
.put_stream_with_options("key", ByteStream::from_static(&[]), 0, options)
.await
});
assert!(matches!(
result.unwrap_err(),
Error::InvalidInput {
field: "checksum",
..
}
));
}
#[tokio::test]
async fn get_bytes_rejects_invalid_key_before_network() {
let bucket = offline_bucket();
let result = bucket.get_bytes("").await;
assert!(matches!(
result.unwrap_err(),
Error::InvalidInput { field: "key", .. }
));
}
#[tokio::test]
async fn put_stream_rejects_auto_checksum_without_precomputed_value() {
let bucket = offline_bucket();
let options = ObjectUploadOptions::new().with_checksum(ChecksumAlgorithm::Sha256);
let result = bucket
.put_stream_with_options("test-key", ByteStream::from_static(b""), 0, options)
.await;
assert!(matches!(
result.unwrap_err(),
Error::InvalidInput {
field: "checksum",
..
}
));
}
#[tokio::test]
async fn copy_object_validates_metadata_options_before_network() {
let bucket = offline_bucket();
let invalid_options =
ObjectUploadOptions::new().with_content_language("invalid language tag!!!");
let result = bucket
.copy_object("src.txt", "dst.txt")
.metadata_directive(r2kit::MetadataDirective::Replace)
.upload_options(invalid_options)
.send()
.await;
assert!(matches!(
result.unwrap_err(),
Error::InvalidInput {
field: "content_language",
..
}
));
}
#[tokio::test]
async fn conditional_headers_validate_and_build_before_network() {
let bucket = offline_bucket();
let now = std::time::SystemTime::now();
let get_req = bucket
.get_object("key.txt")
.if_modified_since(now)
.if_unmodified_since(now)
.if_match("etag-1")
.if_none_match("etag-2");
assert!(format!("{get_req:?}").contains("key.txt"));
let head_req = bucket
.head_object("key.txt")
.if_modified_since(now)
.if_unmodified_since(now)
.if_match("etag-1")
.if_none_match("etag-2");
assert!(format!("{head_req:?}").contains("key.txt"));
let copy_req = bucket
.copy_object("src.txt", "dst.txt")
.source_if_modified_since(now)
.source_if_unmodified_since(now)
.source_if_match("etag-1")
.source_if_none_match("etag-2")
.source_bucket("other-bucket")
.metadata_directive(r2kit::MetadataDirective::Copy);
assert!(format!("{copy_req:?}").contains("src.txt"));
}
#[tokio::test]
async fn copy_object_rejects_conflicting_or_unsupported_options() {
let bucket = offline_bucket();
let err = bucket
.copy_object("src.txt", "dst.txt")
.metadata_directive(r2kit::MetadataDirective::Copy)
.upload_options(ObjectUploadOptions::new())
.send()
.await
.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput {
field: "metadata_directive",
..
}
));
let err = bucket
.copy_object("src.txt", "dst.txt")
.upload_options(ObjectUploadOptions::new().with_checksum(r2kit::ChecksumAlgorithm::Sha256))
.send()
.await
.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput {
field: "checksum",
..
}
));
let err = bucket
.copy_object("src.txt", "dst.txt")
.upload_options(ObjectUploadOptions::new().with_if_match("etag"))
.send()
.await
.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput {
field: "if_match",
..
}
));
}
#[tokio::test]
async fn conditional_headers_reject_newline_injection() {
let bucket = offline_bucket();
let err = bucket
.get_object("key.txt")
.if_match("etag\r\ninjected: true")
.send()
.await
.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput {
field: "if_match",
..
}
));
let err = bucket
.head_object("key.txt")
.if_none_match("etag\ninjected: true")
.send()
.await
.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput {
field: "if_none_match",
..
}
));
let err = bucket
.copy_object("src.txt", "dst.txt")
.source_if_match("etag\r\ninjected: true")
.send()
.await
.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput {
field: "source_if_match",
..
}
));
}
#[tokio::test]
async fn copy_object_defaults_to_replace_directive_when_upload_options_present() {
let bucket = offline_bucket();
let valid_options = ObjectUploadOptions::new().with_content_type(r2kit::mime::TEXT_PLAIN);
let result = bucket
.copy_object("src.txt", "dst.txt")
.upload_options(valid_options)
.send()
.await;
let err = result.unwrap_err();
assert!(!matches!(err, Error::InvalidInput { .. }));
}
#[tokio::test]
async fn download_file_cleans_up_file_on_stream_failure() {
use std::io::Write;
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = std::io::Read::read(&mut stream, &mut buf);
let response =
"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nETag: \"etag\"\r\n\r\npartial";
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
let sdk_config = aws_sdk_s3::config::Builder::new()
.behavior_version_latest()
.region(aws_sdk_s3::config::Region::new("auto"))
.endpoint_url(format!("http://127.0.0.1:{port}"))
.force_path_style(true)
.credentials_provider(aws_sdk_s3::config::Credentials::new(
"dummy-access",
"dummy-secret",
None,
None,
"contract-test",
))
.build();
let client = R2Client::from_sdk(aws_sdk_s3::Client::from_conf(sdk_config));
let bucket = client.bucket("contract-tests").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let target_path = temp_dir.path().join("partial_download.bin");
let result = bucket.download_file("test.bin", &target_path).await;
let _ = server.join();
assert!(matches!(
result.unwrap_err(),
Error::Io {
operation: "download_file"
}
));
assert!(
!target_path.exists(),
"partial file must be deleted if download stream fails"
);
}
#[tokio::test]
async fn list_multipart_uploads_rejects_truncated_response_without_markers() {
use std::io::Write;
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = std::io::Read::read(&mut stream, &mut buf);
let body = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>contract-tests</Bucket>
<IsTruncated>true</IsTruncated>
</ListMultipartUploadsResult>"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
let sdk_config = aws_sdk_s3::config::Builder::new()
.behavior_version_latest()
.region(aws_sdk_s3::config::Region::new("auto"))
.endpoint_url(format!("http://127.0.0.1:{port}"))
.force_path_style(true)
.credentials_provider(aws_sdk_s3::config::Credentials::new(
"dummy-access",
"dummy-secret",
None,
None,
"contract-test",
))
.build();
let client = R2Client::from_sdk(aws_sdk_s3::Client::from_conf(sdk_config));
let bucket = client.bucket("contract-tests").unwrap();
let result = bucket.list_multipart_uploads().send().await;
let _ = server.join();
assert!(matches!(
result.unwrap_err(),
Error::Service {
operation: "ListMultipartUploads"
}
));
}