use std::time::Duration;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqwest::blocking::Client;
use reqwest::header::{HeaderValue, ETAG, IF_MATCH, IF_NONE_MATCH};
use reqwest::StatusCode;
use sha2::{Digest, Sha256};
use local_driver::s3_sign::{
sign_s3_empty_body, sign_s3_get_with_query, sign_s3_no_body, sign_s3_put_object,
};
use crate::{Error, ObjectStore, Precondition};
const R2_REGION: &str = "auto";
pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
pub struct R2ObjectStore {
account_id: String,
bucket: String,
access_key: String,
secret_key: String,
client: Client,
}
impl R2ObjectStore {
pub fn new(
account_id: impl Into<String>,
bucket: impl Into<String>,
access_key: impl Into<String>,
secret_key: impl Into<String>,
) -> Result<Self, Error> {
let client = Client::builder()
.timeout(Duration::from_secs(300))
.build()
.map_err(|e| Error::Backend(format!("reqwest client: {e}")))?;
Ok(Self {
account_id: account_id.into(),
bucket: bucket.into(),
access_key: access_key.into(),
secret_key: secret_key.into(),
client,
})
}
pub fn from_vault(
account_id: impl Into<String>,
bucket: impl Into<String>,
) -> Result<Self, Error> {
let access_key = fob::get_or_env(R2_ACCESS_KEY_SLOT, R2_ACCESS_KEY_ENV)
.map_err(|e| Error::Auth(format!("vault read {R2_ACCESS_KEY_SLOT}: {e}")))?
.ok_or_else(|| {
Error::Auth(format!(
"missing R2 credential: set vault slot {R2_ACCESS_KEY_SLOT} or env {R2_ACCESS_KEY_ENV}"
))
})?;
let secret_key = fob::get_or_env(R2_SECRET_KEY_SLOT, R2_SECRET_KEY_ENV)
.map_err(|e| Error::Auth(format!("vault read {R2_SECRET_KEY_SLOT}: {e}")))?
.ok_or_else(|| {
Error::Auth(format!(
"missing R2 credential: set vault slot {R2_SECRET_KEY_SLOT} or env {R2_SECRET_KEY_ENV}"
))
})?;
Self::new(account_id, bucket, access_key, secret_key)
}
fn endpoint(&self) -> String {
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
}
fn object_url(&self, key: &str) -> String {
format!("{}/{}/{}", self.endpoint(), self.bucket, key)
}
fn bucket_url(&self) -> String {
format!("{}/{}", self.endpoint(), self.bucket)
}
}
fn io_err(ctx: &str, e: impl std::fmt::Display) -> Error {
Error::Io(format!("{ctx}: {e}"))
}
impl ObjectStore for R2ObjectStore {
fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
let url = self.object_url(key);
let body_sha256 = {
let mut h = Sha256::new();
h.update(&data);
hex::encode(h.finalize())
};
let headers = sign_s3_put_object(
&url,
&body_sha256,
DEFAULT_CONTENT_TYPE,
data.len(),
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
let resp = self
.client
.put(&url)
.headers(headers)
.body(data)
.send()
.map_err(|e| io_err(&format!("PUT {key}"), e))?;
check_status(resp, "PUT", key)
}
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
let url = self.object_url(key);
let headers = sign_s3_get_with_query(
&url,
"",
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign GET {key}: {e}")))?;
let resp = self
.client
.get(&url)
.headers(headers)
.send()
.map_err(|e| io_err(&format!("GET {key}"), e))?;
match resp.status() {
StatusCode::OK => {
let bytes = resp
.bytes()
.map_err(|e| io_err(&format!("read GET {key}"), e))?;
Ok(Some(bytes.to_vec()))
}
StatusCode::NOT_FOUND => Ok(None),
s => Err(status_err("GET", key, s, resp.text().ok())),
}
}
fn head(&self, key: &str) -> Result<bool, Error> {
let url = self.object_url(key);
let headers = sign_s3_no_body(
"HEAD",
&url,
"",
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
let resp = self
.client
.head(&url)
.headers(headers)
.send()
.map_err(|e| io_err(&format!("HEAD {key}"), e))?;
match resp.status() {
StatusCode::OK => Ok(true),
StatusCode::NOT_FOUND => Ok(false),
s => Err(status_err("HEAD", key, s, None)),
}
}
fn delete(&self, key: &str) -> Result<(), Error> {
let url = self.object_url(key);
let headers = sign_s3_empty_body(
"DELETE",
&url,
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign DELETE {key}: {e}")))?;
let resp = self
.client
.delete(&url)
.headers(headers)
.send()
.map_err(|e| io_err(&format!("DELETE {key}"), e))?;
match resp.status() {
StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
s => Err(status_err("DELETE", key, s, resp.text().ok())),
}
}
fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
Ok(self
.list_prefix_detailed(prefix)?
.into_iter()
.map(|m| m.key)
.collect())
}
fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
let url = self.object_url(key);
let body_sha256 = {
let mut h = Sha256::new();
h.update(&data);
hex::encode(h.finalize())
};
let mut headers = sign_s3_put_object(
&url,
&body_sha256,
DEFAULT_CONTENT_TYPE,
data.len(),
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
match &cond {
Precondition::IfAbsent => {
headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*"));
}
Precondition::IfMatch(etag) => {
let v = HeaderValue::from_str(etag)
.map_err(|e| Error::Backend(format!("invalid If-Match etag {etag:?}: {e}")))?;
headers.insert(IF_MATCH, v);
}
}
let resp = self
.client
.put(&url)
.headers(headers)
.body(data)
.send()
.map_err(|e| io_err(&format!("PUT(if) {key}"), e))?;
let status = resp.status();
if status == StatusCode::PRECONDITION_FAILED {
return Err(Error::PreconditionFailed(format!(
"put_if {key}: precondition not met ({cond:?})"
)));
}
if !status.is_success() {
return Err(status_err("PUT(if)", key, status, resp.text().ok()));
}
match resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) {
Some(e) => Ok(e.to_string()),
None => self
.etag(key)?
.ok_or_else(|| Error::Backend(format!("PUT(if) {key} returned no ETag"))),
}
}
fn etag(&self, key: &str) -> Result<Option<String>, Error> {
let url = self.object_url(key);
let headers = sign_s3_no_body(
"HEAD",
&url,
"",
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
let resp = self
.client
.head(&url)
.headers(headers)
.send()
.map_err(|e| io_err(&format!("HEAD(etag) {key}"), e))?;
match resp.status() {
StatusCode::OK => Ok(resp
.headers()
.get(ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())),
StatusCode::NOT_FOUND => Ok(None),
s => Err(status_err("HEAD(etag)", key, s, None)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectMeta {
pub key: String,
pub size: u64,
pub last_modified: String,
}
impl R2ObjectStore {
pub fn list_prefix_detailed(&self, prefix: &str) -> Result<Vec<ObjectMeta>, Error> {
let mut entries = Vec::new();
let mut continuation_token: Option<String> = None;
let bucket_url = self.bucket_url();
let encoded_prefix = utf8_percent_encode(prefix, QUERY_VALUE).to_string();
loop {
let mut params: Vec<(String, String)> =
vec![("list-type".to_string(), "2".to_string())];
if let Some(token) = &continuation_token {
let encoded = utf8_percent_encode(token, QUERY_VALUE).to_string();
params.push(("continuation-token".to_string(), encoded));
}
params.push(("prefix".to_string(), encoded_prefix.clone()));
params.sort_by(|a, b| a.0.cmp(&b.0));
let canonical_query = params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
let url_with_query = format!("{bucket_url}?{canonical_query}");
let headers = sign_s3_get_with_query(
&bucket_url,
&canonical_query,
R2_REGION,
&self.access_key,
&self.secret_key,
)
.map_err(|e| Error::Backend(format!("sign LIST {prefix}: {e}")))?;
let resp = self
.client
.get(&url_with_query)
.headers(headers)
.send()
.map_err(|e| io_err(&format!("LIST {prefix}"), e))?;
if !resp.status().is_success() {
return Err(status_err("LIST", prefix, resp.status(), resp.text().ok()));
}
let body = resp
.text()
.map_err(|e| io_err(&format!("LIST {prefix} body"), e))?;
let (page_entries, next_token) = parse_list_v2_detailed(&body);
entries.extend(page_entries);
if let Some(t) = next_token {
continuation_token = Some(t);
} else {
break;
}
}
Ok(entries)
}
}
fn check_status(resp: reqwest::blocking::Response, verb: &str, key: &str) -> Result<(), Error> {
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp.text().ok();
Err(status_err(verb, key, status, body))
}
}
fn status_err(verb: &str, key: &str, status: StatusCode, body: Option<String>) -> Error {
let snippet = body
.as_deref()
.map(|s| s.chars().take(200).collect::<String>())
.unwrap_or_default();
let msg = format!("{verb} {key} → {status} {snippet}");
match status {
StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(msg),
StatusCode::NOT_FOUND => Error::NotFound(msg),
_ => Error::Backend(msg),
}
}
fn parse_list_v2(body: &str) -> (Vec<String>, Option<String>) {
let keys = extract_all_tags(body, "Key");
let next = extract_first_tag(body, "NextContinuationToken");
let truncated = extract_first_tag(body, "IsTruncated")
.map(|v| v.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false);
(keys, if truncated { next } else { None })
}
fn parse_list_v2_detailed(body: &str) -> (Vec<ObjectMeta>, Option<String>) {
let blocks = extract_all_tags(body, "Contents");
let entries = blocks
.into_iter()
.filter_map(|block| {
let key = extract_first_tag(&block, "Key")?;
let size = extract_first_tag(&block, "Size")?.trim().parse::<u64>().ok()?;
let last_modified = extract_first_tag(&block, "LastModified")?;
Some(ObjectMeta { key, size, last_modified })
})
.collect();
let next = extract_first_tag(body, "NextContinuationToken");
let truncated = extract_first_tag(body, "IsTruncated")
.map(|v| v.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false);
(entries, if truncated { next } else { None })
}
fn extract_all_tags(body: &str, tag: &str) -> Vec<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let mut out = Vec::new();
let mut search = body;
while let Some(start) = search.find(&open) {
let content_start = start + open.len();
if let Some(end) = search[content_start..].find(&close) {
out.push(search[content_start..content_start + end].to_string());
search = &search[content_start + end + close.len()..];
} else {
break;
}
}
out
}
fn extract_first_tag(body: &str, tag: &str) -> Option<String> {
extract_all_tags(body, tag).into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_list_v2_extracts_keys() {
let body = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<IsTruncated>false</IsTruncated>
<Contents><Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key></Contents>
<Contents><Key>yubaba/release-manifest.json</Key></Contents>
</ListBucketResult>"#;
let (keys, next) = parse_list_v2(body);
assert_eq!(
keys,
vec![
"yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz".to_string(),
"yubaba/release-manifest.json".to_string(),
]
);
assert!(next.is_none());
}
#[test]
fn parse_list_v2_returns_continuation_when_truncated() {
let body = r#"<ListBucketResult>
<IsTruncated>true</IsTruncated>
<NextContinuationToken>abc123</NextContinuationToken>
<Contents><Key>a</Key></Contents>
</ListBucketResult>"#;
let (keys, next) = parse_list_v2(body);
assert_eq!(keys, vec!["a".to_string()]);
assert_eq!(next.as_deref(), Some("abc123"));
}
#[test]
fn parse_list_v2_ignores_token_when_not_truncated() {
let body = r#"<ListBucketResult>
<IsTruncated>false</IsTruncated>
<NextContinuationToken>stale</NextContinuationToken>
<Contents><Key>a</Key></Contents>
</ListBucketResult>"#;
let (_, next) = parse_list_v2(body);
assert!(next.is_none());
}
#[test]
fn parse_list_v2_detailed_extracts_size_and_mtime() {
let body = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key>
<LastModified>2026-06-08T20:14:32.000Z</LastModified>
<ETag>"abc"</ETag>
<Size>4823104</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>yubaba/release-manifest.json</Key>
<LastModified>2026-06-08T20:14:35.000Z</LastModified>
<Size>412</Size>
</Contents>
</ListBucketResult>"#;
let (entries, next) = parse_list_v2_detailed(body);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].key, "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz");
assert_eq!(entries[0].size, 4823104);
assert_eq!(entries[0].last_modified, "2026-06-08T20:14:32.000Z");
assert_eq!(entries[1].key, "yubaba/release-manifest.json");
assert_eq!(entries[1].size, 412);
assert!(next.is_none());
}
#[test]
fn r2_object_store_constructs_with_explicit_keys() {
let s = R2ObjectStore::new("acct", "yah-dev", "AK", "SK").unwrap();
assert_eq!(s.object_url("k"), "https://acct.r2.cloudflarestorage.com/yah-dev/k");
assert_eq!(s.bucket_url(), "https://acct.r2.cloudflarestorage.com/yah-dev");
}
#[test]
fn object_url_preserves_slashes_in_key() {
let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
assert_eq!(
s.object_url("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"),
"https://acct.r2.cloudflarestorage.com/b/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"
);
}
}