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,
sign_s3_put_object_with, S3PutOptions,
};
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";
fn content_type_for_key(key: &str) -> &'static str {
let ext = match key.rsplit_once('.') {
Some((_, e)) if !e.contains('/') => e,
_ => "",
};
match ext.to_ascii_lowercase().as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" | "map" => "application/json",
"webmanifest" => "application/manifest+json",
"xml" => "application/xml",
"txt" => "text/plain; charset=utf-8",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"avif" => "image/avif",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"wasm" => "application/wasm",
"pdf" => "application/pdf",
_ => DEFAULT_CONTENT_TYPE,
}
}
pub struct R2ObjectStore {
account_id: String,
bucket: String,
access_key: String,
secret_key: String,
endpoint: Option<String>,
client: Option<Client>,
}
impl Drop for R2ObjectStore {
fn drop(&mut self) {
let Some(client) = self.client.take() else { return };
std::thread::spawn(move || drop(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(),
endpoint: None,
client: Some(client),
})
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
let endpoint = endpoint.into();
let trimmed = endpoint.trim_end_matches('/');
self.endpoint = (!trimmed.is_empty()).then(|| trimmed.to_string());
self
}
fn client(&self) -> &Client {
self.client
.as_ref()
.expect("client is Some until Drop takes it")
}
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 {
match &self.endpoint {
Some(e) => e.clone(),
None => 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 put_inner(
&self,
key: &str,
data: Vec<u8>,
cache_control: Option<&str>,
) -> 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_with(
&url,
&body_sha256,
data.len(),
R2_REGION,
&self.access_key,
&self.secret_key,
&S3PutOptions {
content_type: content_type_for_key(key),
blake3_meta: None,
cache_control,
},
)
.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 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> {
self.put_inner(key, data, None)
}
fn put_cached(&self, key: &str, data: Vec<u8>, cache_control: &str) -> Result<(), Error> {
self.put_inner(key, data, Some(cache_control))
}
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,
content_type_for_key(key),
data.len(),
R2_REGION,
&self.access_key,
&self.secret_key,
None,
)
.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 with_endpoint_redirects_every_url_and_leaves_r2_alone() {
let store = R2ObjectStore::new("acct", "yah-dev", "k", "s").unwrap();
assert_eq!(
store.object_url("yah/index.json"),
"https://acct.r2.cloudflarestorage.com/yah-dev/yah/index.json"
);
let pond = R2ObjectStore::new("pond", "yah-dev", "k", "s")
.unwrap()
.with_endpoint("http://127.0.0.1:9000");
assert_eq!(
pond.object_url("yah/index.json"),
"http://127.0.0.1:9000/yah-dev/yah/index.json"
);
assert_eq!(pond.bucket_url(), "http://127.0.0.1:9000/yah-dev");
}
#[test]
fn with_endpoint_normalizes_trailing_slash_and_ignores_empty() {
let s = R2ObjectStore::new("acct", "b", "k", "s")
.unwrap()
.with_endpoint("http://127.0.0.1:9000/");
assert_eq!(s.object_url("k1"), "http://127.0.0.1:9000/b/k1");
let s = R2ObjectStore::new("acct", "b", "k", "s")
.unwrap()
.with_endpoint("");
assert_eq!(
s.object_url("k1"),
"https://acct.r2.cloudflarestorage.com/b/k1"
);
}
fn one_shot_http() -> (String, std::thread::JoinHandle<String>) {
use std::io::{BufRead, BufReader, Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut head = String::new();
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap() == 0 {
break;
}
let done = line == "\r\n";
head.push_str(&line);
if done {
break;
}
}
let len: usize = head
.lines()
.find_map(|l| {
l.strip_prefix("content-length: ")
.or_else(|| l.strip_prefix("Content-Length: "))
})
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
let mut body = vec![0u8; len];
reader.read_exact(&mut body).unwrap();
reader
.into_inner()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.unwrap();
head
});
(url, handle)
}
#[test]
fn put_cached_sends_the_cache_control_header_on_the_wire() {
let (endpoint, server) = one_shot_http();
let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
.unwrap()
.with_endpoint(endpoint);
store
.put_cached(
"yah-desktop/latest.json",
b"{\"version\":\"0.8.22\"}".to_vec(),
crate::CACHE_CONTROL_NO_CACHE,
)
.unwrap();
let head = server.join().unwrap().to_lowercase();
assert!(
head.starts_with("put /yah-dev/yah-desktop/latest.json "),
"{head}"
);
assert!(head.contains("cache-control: no-cache, max-age=0\r\n"), "{head}");
assert!(
head.contains("signedheaders=cache-control;content-length;content-type;host;"),
"{head}"
);
}
#[test]
fn a_plain_put_sends_no_cache_control_header() {
let (endpoint, server) = one_shot_http();
let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
.unwrap()
.with_endpoint(endpoint);
store.put("some/blob.bin", b"bytes".to_vec()).unwrap();
let head = server.join().unwrap().to_lowercase();
assert!(!head.contains("cache-control"), "{head}");
}
#[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"
);
}
#[test]
fn content_type_inferred_from_extension() {
assert_eq!(
content_type_for_key("yah-marketing/cloud/index.html"),
"text/html; charset=utf-8"
);
assert_eq!(content_type_for_key("app.css"), "text/css; charset=utf-8");
assert_eq!(content_type_for_key("bundle.mjs"), "text/javascript; charset=utf-8");
assert_eq!(content_type_for_key("illustrations/horse.webp"), "image/webp");
assert_eq!(content_type_for_key("manifest.json"), "application/json");
assert_eq!(content_type_for_key("pointers/releases"), DEFAULT_CONTENT_TYPE);
assert_eq!(content_type_for_key("v1.2/binary"), DEFAULT_CONTENT_TYPE);
}
}