use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::sigv4::{self, Credentials, SignableRequest};
use cratefield_core::{
Blob, BlobError, BlobMeta, BlobObject, BlobPage, BlobStream, BoxStream, Clock, MAX_LIST_LIMIT,
MAX_MULTIPART_PARTS, PartReceipt, PendingUpload, PresignedPut, StreamError, UploadId,
check_blob_size, check_part_number, limit_stream,
};
use futures_core::Stream;
use futures_util::StreamExt;
use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll};
use std::time::Duration;
use worker::send::{IntoSendFuture, SendWrapper};
use worker::{Bucket, Data, HttpMetadata, Include};
const R2_REGION: &str = "auto";
const R2_SERVICE: &str = "s3";
fn op_err(err: &worker::Error) -> BlobError {
BlobError::Operation(err.to_string())
}
fn unsupported_presign() -> BlobError {
BlobError::Unsupported(
"R2 presigning needs the S3-compatible API and its access keys; configure \
Cloudflare::blob_presign(account_id, access_key_id, secret_access_key, bucket), \
or serve the bytes through the harness"
.to_owned(),
)
}
fn presigner(presigner: Option<&R2Presigner>) -> Result<&R2Presigner, BlobError> {
presigner.ok_or_else(unsupported_presign)
}
pub(crate) struct R2Presigner {
account_id: String,
bucket: String,
credentials: Credentials,
clock: Arc<dyn Clock>,
}
impl R2Presigner {
pub(crate) fn new(
account_id: impl Into<String>,
bucket: impl Into<String>,
credentials: Credentials,
clock: Arc<dyn Clock>,
) -> Self {
Self {
account_id: account_id.into(),
bucket: bucket.into(),
credentials,
clock,
}
}
fn sign(&self, method: &str, key: &str, headers: &[(String, String)], ttl: Duration) -> String {
let host = format!("{}.r2.cloudflarestorage.com", self.account_id);
let path = format!("/{}{}", self.bucket, sigv4::s3_key_path(key));
sigv4::presign(
&self.credentials,
R2_REGION,
R2_SERVICE,
&SignableRequest {
method,
host: &host,
path: &path,
query: &[],
headers,
},
ttl.as_secs().max(1),
self.clock.now(),
)
}
fn signed_url(&self, key: &str, ttl: Duration) -> String {
self.sign("GET", key, &[], ttl)
}
fn signed_put_url(
&self,
key: &str,
content_type: &str,
content_length: Option<u64>,
ttl: Duration,
) -> PresignedPut {
let mut headers = vec![("content-type".to_owned(), content_type.to_owned())];
if let Some(length) = content_length {
headers.push(("content-length".to_owned(), length.to_string()));
}
PresignedPut {
url: self.sign("PUT", key, &headers, ttl),
method: "PUT",
headers,
}
}
}
impl fmt::Debug for R2Presigner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("R2Presigner")
.field("account_id", &self.account_id)
.field("bucket", &self.bucket)
.field("credentials", &self.credentials)
.finish_non_exhaustive()
}
}
pub(crate) struct R2Blob {
bucket: Bucket,
presigner: Option<R2Presigner>,
}
impl R2Blob {
pub(crate) fn new(bucket: Bucket, presigner: Option<R2Presigner>) -> Self {
Self { bucket, presigner }
}
async fn put_bytes(
&self,
key: &str,
bytes: &[u8],
content_type: &str,
) -> Result<(), BlobError> {
self.bucket
.put(key, bytes.to_vec())
.http_metadata(HttpMetadata {
content_type: Some(content_type.to_owned()),
..Default::default()
})
.execute()
.into_send()
.await
.map(|_| ())
.map_err(|err| op_err(&err))
}
async fn create_upload(
&self,
key: &str,
content_type: &str,
) -> Result<worker::MultipartUpload, BlobError> {
self.bucket
.create_multipart_upload(key)
.http_metadata(HttpMetadata {
content_type: Some(content_type.to_owned()),
..Default::default()
})
.execute()
.into_send()
.await
.map_err(|err| op_err(&err))
}
async fn read_part(
&self,
body: BoxStream<'static, Result<Bytes, StreamError>>,
max_part_bytes: u64,
) -> Result<Vec<u8>, BlobError> {
let mut limited = limit_stream(body, max_part_bytes);
let mut buffered = Vec::new();
while let Some(chunk) = limited.next().await {
buffered.extend_from_slice(&chunk.map_err(BlobError::from)?);
}
Ok(buffered)
}
async fn upload_one_part(
&self,
upload: &worker::MultipartUpload,
parts: &mut Vec<worker::UploadedPart>,
part_number: &mut u16,
part: Vec<u8>,
) -> Result<(), BlobError> {
if u64::from(*part_number) > u64::from(MAX_MULTIPART_PARTS) {
return Err(BlobError::TooLarge(format!(
"streamed object needs more than {MAX_MULTIPART_PARTS} parts"
)));
}
let uploaded = upload
.upload_part(*part_number, Data::Bytes(part))
.into_send()
.await
.map_err(|err| op_err(&err))?;
parts.push(uploaded);
*part_number = part_number.saturating_add(1);
Ok(())
}
async fn put_stream_multipart(
&self,
key: &str,
content_type: &str,
mut limited: BoxStream<'static, Result<Bytes, StreamError>>,
mut buffered: Vec<u8>,
) -> Result<u64, BlobError> {
let upload = self.create_upload(key, content_type).await?;
let mut parts: Vec<worker::UploadedPart> = Vec::new();
let mut part_number: u16 = 1;
let mut written: u64 = 0;
loop {
while buffered.len() >= PUT_STREAM_PART_BYTES {
let part: Vec<u8> = buffered.drain(..PUT_STREAM_PART_BYTES).collect();
written += part.len() as u64;
if let Err(err) = self
.upload_one_part(&upload, &mut parts, &mut part_number, part)
.await
{
let _ = upload.abort().into_send().await;
return Err(err);
}
}
match limited.next().await {
Some(Ok(chunk)) => buffered.extend_from_slice(&chunk),
Some(Err(err)) => {
let _ = upload.abort().into_send().await;
return Err(BlobError::from(err));
}
None => break,
}
}
if !buffered.is_empty() {
let part = std::mem::take(&mut buffered);
written += part.len() as u64;
if let Err(err) = self
.upload_one_part(&upload, &mut parts, &mut part_number, part)
.await
{
let _ = upload.abort().into_send().await;
return Err(err);
}
}
let upload_id = upload.upload_id().into_send().await;
if let Err(err) = upload.complete(parts).into_send().await {
if let Ok(resumed) = self.bucket.resume_multipart_upload(key, upload_id.as_str()) {
let _ = resumed.abort().into_send().await;
}
return Err(op_err(&err));
}
Ok(written)
}
}
fn content_type_or_default(metadata: &HttpMetadata) -> String {
metadata
.content_type
.clone()
.unwrap_or_else(|| "application/octet-stream".to_owned())
}
const PUT_STREAM_PART_BYTES: usize = 8 * 1024 * 1024;
type BoxedWorkerStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, worker::Error>>>>;
struct SendBlobStream(SendWrapper<BoxedWorkerStream>);
impl Stream for SendBlobStream {
type Item = Result<Bytes, StreamError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
match self.get_mut().0.0.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(Bytes::from(chunk)))),
Poll::Ready(Some(Err(err))) => {
Poll::Ready(Some(Err(StreamError::Transport(err.to_string()))))
}
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
fn blob_body_stream(stream: worker::ByteStream) -> BoxStream<'static, Result<Bytes, StreamError>> {
let boxed: Box<dyn Stream<Item = Result<Vec<u8>, worker::Error>>> = Box::new(stream);
Box::pin(SendBlobStream(SendWrapper::new(Pin::from(boxed))))
}
fn empty_body_stream() -> BoxStream<'static, Result<Bytes, StreamError>> {
Box::pin(futures_util::stream::empty::<Result<Bytes, StreamError>>())
}
#[async_trait]
impl Blob for R2Blob {
async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
check_blob_size(bytes)?;
self.bucket
.put(key, bytes.to_vec())
.http_metadata(HttpMetadata {
content_type: Some(content_type.to_owned()),
..Default::default()
})
.execute()
.into_send()
.await
.map(|_| ())
.map_err(|err| op_err(&err))
}
async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
let Some(object) = self
.bucket
.get(key)
.execute()
.into_send()
.await
.map_err(|err| op_err(&err))?
else {
return Ok(None);
};
let content_type = object
.http_metadata()
.content_type
.unwrap_or_else(|| "application/octet-stream".to_owned());
let bytes = match object.body() {
Some(body) => body.bytes().into_send().await.map_err(|err| op_err(&err))?,
None => Vec::new(),
};
Ok(Some(BlobObject {
bytes,
content_type,
}))
}
async fn delete(&self, key: &str) -> Result<(), BlobError> {
self.bucket
.delete(key)
.into_send()
.await
.map_err(|err| op_err(&err))
}
async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
Ok(presigner(self.presigner.as_ref())?.signed_url(key, ttl))
}
async fn signed_put_url(
&self,
key: &str,
content_type: &str,
content_length: Option<u64>,
ttl: Duration,
) -> Result<PresignedPut, BlobError> {
Ok(presigner(self.presigner.as_ref())?.signed_put_url(
key,
content_type,
content_length,
ttl,
))
}
async fn put_stream(
&self,
key: &str,
body: BoxStream<'static, Result<Bytes, StreamError>>,
content_type: &str,
max_bytes: u64,
) -> Result<u64, BlobError> {
let mut limited = limit_stream(body, max_bytes);
let mut buffered: Vec<u8> = Vec::new();
let mut ended = false;
while buffered.len() < PUT_STREAM_PART_BYTES {
match limited.next().await {
Some(Ok(chunk)) => buffered.extend_from_slice(&chunk),
Some(Err(err)) => return Err(BlobError::from(err)),
None => {
ended = true;
break;
}
}
}
if ended {
let written = buffered.len() as u64;
self.put_bytes(key, &buffered, content_type).await?;
return Ok(written);
}
self.put_stream_multipart(key, content_type, limited, buffered)
.await
}
async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
let Some(object) = self
.bucket
.get(key)
.execute()
.into_send()
.await
.map_err(|err| op_err(&err))?
else {
return Ok(None);
};
let size = object.size();
let content_type = content_type_or_default(&object.http_metadata());
let body = match object.body() {
Some(body) => blob_body_stream(body.stream().map_err(|err| op_err(&err))?),
None => empty_body_stream(),
};
Ok(Some(BlobStream {
content_type,
size,
body,
}))
}
async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
let Some(object) = self
.bucket
.head(key)
.into_send()
.await
.map_err(|err| op_err(&err))?
else {
return Ok(None);
};
Ok(Some(BlobMeta {
key: object.key(),
size: object.size(),
content_type: content_type_or_default(&object.http_metadata()),
}))
}
async fn list(
&self,
prefix: &str,
cursor: Option<&str>,
limit: usize,
) -> Result<BlobPage, BlobError> {
let limit =
u32::try_from(limit.clamp(1, MAX_LIST_LIMIT)).expect("MAX_LIST_LIMIT fits a u32");
let mut builder = self
.bucket
.list()
.prefix(prefix)
.limit(limit)
.include(vec![Include::HttpMetadata]);
if let Some(cursor) = cursor {
builder = builder.cursor(cursor);
}
let objects = builder
.execute()
.into_send()
.await
.map_err(|err| op_err(&err))?;
let page = objects
.objects()
.iter()
.map(|object| BlobMeta {
key: object.key(),
size: object.size(),
content_type: content_type_or_default(&object.http_metadata()),
})
.collect();
let cursor = if objects.truncated() {
objects.cursor()
} else {
None
};
Ok(BlobPage {
objects: page,
cursor,
})
}
async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
let upload = self.create_upload(key, content_type).await?;
let id = upload.upload_id().into_send().await;
Ok(UploadId::new(id))
}
async fn upload_part(
&self,
key: &str,
upload_id: &UploadId,
part_number: u16,
body: BoxStream<'static, Result<Bytes, StreamError>>,
max_part_bytes: u64,
) -> Result<PartReceipt, BlobError> {
check_part_number(part_number)?;
let buffered = self.read_part(body, max_part_bytes).await?;
let upload = self
.bucket
.resume_multipart_upload(key, upload_id.as_str())
.map_err(|err| op_err(&err))?;
let uploaded = upload
.upload_part(part_number, Data::Bytes(buffered))
.into_send()
.await
.map_err(|err| op_err(&err))?;
Ok(PartReceipt {
part_number,
etag: uploaded.etag(),
})
}
async fn complete_multipart(
&self,
key: &str,
upload_id: &UploadId,
parts: &[PartReceipt],
) -> Result<(), BlobError> {
if parts.is_empty() {
return Err(BlobError::Operation(
"a multipart upload needs at least one part".to_owned(),
));
}
let upload = self
.bucket
.resume_multipart_upload(key, upload_id.as_str())
.map_err(|err| op_err(&err))?;
let uploaded: Vec<worker::UploadedPart> = parts
.iter()
.map(|receipt| worker::UploadedPart::new(receipt.part_number, receipt.etag.clone()))
.collect();
upload
.complete(uploaded)
.into_send()
.await
.map_err(|err| op_err(&err))?;
Ok(())
}
async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
let upload = self
.bucket
.resume_multipart_upload(key, upload_id.as_str())
.map_err(|err| op_err(&err))?;
upload.abort().into_send().await.map_err(|err| op_err(&err))
}
async fn list_multipart_uploads(&self, _prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
Err(BlobError::Unsupported(
"the Workers R2 binding cannot list multipart uploads; configure an R2 \
lifecycle rule that aborts incomplete multipart uploads"
.to_owned(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::sigv4::{SigV4Error, verify_presigned};
use time::OffsetDateTime;
const ACCESS_KEY: &str = "AKIAEXAMPLE";
const SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
const KEY: &str = "renders/job 1/out.mp4";
const PATH: &str = "/media/renders/job%201/out.mp4";
struct TestClock(OffsetDateTime);
#[async_trait]
impl Clock for TestClock {
fn now(&self) -> OffsetDateTime {
self.0
}
}
fn at(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> OffsetDateTime {
let date =
time::Date::from_calendar_date(year, time::Month::try_from(month).expect("month"), day)
.expect("a valid test date");
let time = time::Time::from_hms(hour, minute, second).expect("a valid test time");
time::PrimitiveDateTime::new(date, time).assume_utc()
}
fn credentials() -> Credentials {
Credentials::new(ACCESS_KEY, SECRET_KEY)
}
fn presigner_at(now: OffsetDateTime) -> R2Presigner {
R2Presigner::new("acct123", "media", credentials(), Arc::new(TestClock(now)))
}
#[test]
fn a_signed_get_url_has_the_r2_shape_and_verifies() {
let now = at(2026, 1, 1, 0, 0, 0);
let url = presigner_at(now).signed_url(KEY, Duration::from_secs(600));
assert!(
url.starts_with(&format!("https://acct123.r2.cloudflarestorage.com{PATH}?")),
"{url}"
);
verify_presigned(&url, "GET", &[], &credentials(), R2_REGION, R2_SERVICE, now)
.expect("the URL verifies against the same credentials");
}
#[test]
fn a_signed_put_url_signs_content_type_and_length() {
let now = at(2026, 1, 1, 0, 0, 0);
let put = presigner_at(now).signed_put_url(
KEY,
"video/mp4",
Some(1024),
Duration::from_secs(600),
);
assert_eq!(put.method, "PUT");
assert_eq!(
put.headers,
vec![
("content-type".to_owned(), "video/mp4".to_owned()),
("content-length".to_owned(), "1024".to_owned()),
]
);
verify_presigned(
&put.url,
"PUT",
&put.headers,
&credentials(),
R2_REGION,
R2_SERVICE,
now,
)
.expect("the signed headers verify");
}
#[test]
fn a_url_is_refused_after_it_expires() {
let url = presigner_at(at(2026, 1, 1, 0, 0, 0)).signed_url(KEY, Duration::from_secs(600));
let verify_at =
|now| verify_presigned(&url, "GET", &[], &credentials(), R2_REGION, R2_SERVICE, now);
verify_at(at(2026, 1, 1, 0, 10, 0)).expect("the deadline itself still verifies");
assert_eq!(
verify_at(at(2026, 1, 1, 0, 10, 1)),
Err(SigV4Error::Expired)
);
}
#[test]
fn presigning_is_unsupported_without_a_presigner() {
assert!(matches!(presigner(None), Err(BlobError::Unsupported(_))));
assert!(presigner(Some(&presigner_at(at(2026, 1, 1, 0, 0, 0)))).is_ok());
}
}