1use std::time::Duration;
21
22use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
23use reqwest::blocking::Client;
24use reqwest::header::{HeaderValue, ETAG, IF_MATCH, IF_NONE_MATCH};
25use reqwest::StatusCode;
26use sha2::{Digest, Sha256};
27
28use local_driver::s3_sign::{
29 sign_s3_empty_body, sign_s3_get_with_query, sign_s3_no_body, sign_s3_put_object,
30};
31
32use crate::{Error, ObjectStore, Precondition};
33
34const R2_REGION: &str = "auto";
36
37pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
39pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
41pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
43pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
45
46const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
50 .remove(b'-')
51 .remove(b'_')
52 .remove(b'.')
53 .remove(b'~');
54
55const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
57
58pub struct R2ObjectStore {
64 account_id: String,
65 bucket: String,
66 access_key: String,
67 secret_key: String,
68 client: Client,
69}
70
71impl R2ObjectStore {
72 pub fn new(
77 account_id: impl Into<String>,
78 bucket: impl Into<String>,
79 access_key: impl Into<String>,
80 secret_key: impl Into<String>,
81 ) -> Result<Self, Error> {
82 let client = Client::builder()
83 .timeout(Duration::from_secs(300))
84 .build()
85 .map_err(|e| Error::Backend(format!("reqwest client: {e}")))?;
86 Ok(Self {
87 account_id: account_id.into(),
88 bucket: bucket.into(),
89 access_key: access_key.into(),
90 secret_key: secret_key.into(),
91 client,
92 })
93 }
94
95 pub fn from_vault(
101 account_id: impl Into<String>,
102 bucket: impl Into<String>,
103 ) -> Result<Self, Error> {
104 let access_key = fob::get_or_env(R2_ACCESS_KEY_SLOT, R2_ACCESS_KEY_ENV)
105 .map_err(|e| Error::Auth(format!("vault read {R2_ACCESS_KEY_SLOT}: {e}")))?
106 .ok_or_else(|| {
107 Error::Auth(format!(
108 "missing R2 credential: set vault slot {R2_ACCESS_KEY_SLOT} or env {R2_ACCESS_KEY_ENV}"
109 ))
110 })?;
111 let secret_key = fob::get_or_env(R2_SECRET_KEY_SLOT, R2_SECRET_KEY_ENV)
112 .map_err(|e| Error::Auth(format!("vault read {R2_SECRET_KEY_SLOT}: {e}")))?
113 .ok_or_else(|| {
114 Error::Auth(format!(
115 "missing R2 credential: set vault slot {R2_SECRET_KEY_SLOT} or env {R2_SECRET_KEY_ENV}"
116 ))
117 })?;
118 Self::new(account_id, bucket, access_key, secret_key)
119 }
120
121 fn endpoint(&self) -> String {
122 format!("https://{}.r2.cloudflarestorage.com", self.account_id)
123 }
124
125 fn object_url(&self, key: &str) -> String {
126 format!("{}/{}/{}", self.endpoint(), self.bucket, key)
127 }
128
129 fn bucket_url(&self) -> String {
130 format!("{}/{}", self.endpoint(), self.bucket)
131 }
132}
133
134fn io_err(ctx: &str, e: impl std::fmt::Display) -> Error {
136 Error::Io(format!("{ctx}: {e}"))
137}
138
139impl ObjectStore for R2ObjectStore {
140 fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
141 let url = self.object_url(key);
142 let body_sha256 = {
143 let mut h = Sha256::new();
144 h.update(&data);
145 hex::encode(h.finalize())
146 };
147 let headers = sign_s3_put_object(
148 &url,
149 &body_sha256,
150 DEFAULT_CONTENT_TYPE,
151 data.len(),
152 R2_REGION,
153 &self.access_key,
154 &self.secret_key,
155 )
156 .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
157
158 let resp = self
159 .client
160 .put(&url)
161 .headers(headers)
162 .body(data)
163 .send()
164 .map_err(|e| io_err(&format!("PUT {key}"), e))?;
165 check_status(resp, "PUT", key)
166 }
167
168 fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
169 let url = self.object_url(key);
170 let headers = sign_s3_get_with_query(
176 &url,
177 "",
178 R2_REGION,
179 &self.access_key,
180 &self.secret_key,
181 )
182 .map_err(|e| Error::Backend(format!("sign GET {key}: {e}")))?;
183
184 let resp = self
185 .client
186 .get(&url)
187 .headers(headers)
188 .send()
189 .map_err(|e| io_err(&format!("GET {key}"), e))?;
190
191 match resp.status() {
192 StatusCode::OK => {
193 let bytes = resp
194 .bytes()
195 .map_err(|e| io_err(&format!("read GET {key}"), e))?;
196 Ok(Some(bytes.to_vec()))
197 }
198 StatusCode::NOT_FOUND => Ok(None),
199 s => Err(status_err("GET", key, s, resp.text().ok())),
200 }
201 }
202
203 fn head(&self, key: &str) -> Result<bool, Error> {
204 let url = self.object_url(key);
205 let headers = sign_s3_no_body(
207 "HEAD",
208 &url,
209 "",
210 R2_REGION,
211 &self.access_key,
212 &self.secret_key,
213 )
214 .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
215
216 let resp = self
217 .client
218 .head(&url)
219 .headers(headers)
220 .send()
221 .map_err(|e| io_err(&format!("HEAD {key}"), e))?;
222
223 match resp.status() {
224 StatusCode::OK => Ok(true),
225 StatusCode::NOT_FOUND => Ok(false),
226 s => Err(status_err("HEAD", key, s, None)),
227 }
228 }
229
230 fn delete(&self, key: &str) -> Result<(), Error> {
231 let url = self.object_url(key);
232 let headers = sign_s3_empty_body(
233 "DELETE",
234 &url,
235 R2_REGION,
236 &self.access_key,
237 &self.secret_key,
238 )
239 .map_err(|e| Error::Backend(format!("sign DELETE {key}: {e}")))?;
240
241 let resp = self
242 .client
243 .delete(&url)
244 .headers(headers)
245 .send()
246 .map_err(|e| io_err(&format!("DELETE {key}"), e))?;
247
248 match resp.status() {
249 StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
252 s => Err(status_err("DELETE", key, s, resp.text().ok())),
253 }
254 }
255
256 fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
257 Ok(self
258 .list_prefix_detailed(prefix)?
259 .into_iter()
260 .map(|m| m.key)
261 .collect())
262 }
263
264 fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
265 let url = self.object_url(key);
266 let body_sha256 = {
267 let mut h = Sha256::new();
268 h.update(&data);
269 hex::encode(h.finalize())
270 };
271 let mut headers = sign_s3_put_object(
277 &url,
278 &body_sha256,
279 DEFAULT_CONTENT_TYPE,
280 data.len(),
281 R2_REGION,
282 &self.access_key,
283 &self.secret_key,
284 )
285 .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
286
287 match &cond {
288 Precondition::IfAbsent => {
289 headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*"));
290 }
291 Precondition::IfMatch(etag) => {
292 let v = HeaderValue::from_str(etag)
293 .map_err(|e| Error::Backend(format!("invalid If-Match etag {etag:?}: {e}")))?;
294 headers.insert(IF_MATCH, v);
295 }
296 }
297
298 let resp = self
299 .client
300 .put(&url)
301 .headers(headers)
302 .body(data)
303 .send()
304 .map_err(|e| io_err(&format!("PUT(if) {key}"), e))?;
305
306 let status = resp.status();
307 if status == StatusCode::PRECONDITION_FAILED {
308 return Err(Error::PreconditionFailed(format!(
309 "put_if {key}: precondition not met ({cond:?})"
310 )));
311 }
312 if !status.is_success() {
313 return Err(status_err("PUT(if)", key, status, resp.text().ok()));
314 }
315 match resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) {
318 Some(e) => Ok(e.to_string()),
319 None => self
320 .etag(key)?
321 .ok_or_else(|| Error::Backend(format!("PUT(if) {key} returned no ETag"))),
322 }
323 }
324
325 fn etag(&self, key: &str) -> Result<Option<String>, Error> {
326 let url = self.object_url(key);
327 let headers = sign_s3_no_body(
329 "HEAD",
330 &url,
331 "",
332 R2_REGION,
333 &self.access_key,
334 &self.secret_key,
335 )
336 .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
337
338 let resp = self
339 .client
340 .head(&url)
341 .headers(headers)
342 .send()
343 .map_err(|e| io_err(&format!("HEAD(etag) {key}"), e))?;
344
345 match resp.status() {
346 StatusCode::OK => Ok(resp
347 .headers()
348 .get(ETAG)
349 .and_then(|v| v.to_str().ok())
350 .map(|s| s.to_string())),
351 StatusCode::NOT_FOUND => Ok(None),
352 s => Err(status_err("HEAD(etag)", key, s, None)),
353 }
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct ObjectMeta {
360 pub key: String,
362 pub size: u64,
364 pub last_modified: String,
366}
367
368impl R2ObjectStore {
369 pub fn list_prefix_detailed(&self, prefix: &str) -> Result<Vec<ObjectMeta>, Error> {
375 let mut entries = Vec::new();
376 let mut continuation_token: Option<String> = None;
377 let bucket_url = self.bucket_url();
378 let encoded_prefix = utf8_percent_encode(prefix, QUERY_VALUE).to_string();
379
380 loop {
381 let mut params: Vec<(String, String)> =
384 vec![("list-type".to_string(), "2".to_string())];
385 if let Some(token) = &continuation_token {
386 let encoded = utf8_percent_encode(token, QUERY_VALUE).to_string();
387 params.push(("continuation-token".to_string(), encoded));
388 }
389 params.push(("prefix".to_string(), encoded_prefix.clone()));
390 params.sort_by(|a, b| a.0.cmp(&b.0));
391 let canonical_query = params
392 .iter()
393 .map(|(k, v)| format!("{k}={v}"))
394 .collect::<Vec<_>>()
395 .join("&");
396
397 let url_with_query = format!("{bucket_url}?{canonical_query}");
398
399 let headers = sign_s3_get_with_query(
400 &bucket_url,
401 &canonical_query,
402 R2_REGION,
403 &self.access_key,
404 &self.secret_key,
405 )
406 .map_err(|e| Error::Backend(format!("sign LIST {prefix}: {e}")))?;
407
408 let resp = self
409 .client
410 .get(&url_with_query)
411 .headers(headers)
412 .send()
413 .map_err(|e| io_err(&format!("LIST {prefix}"), e))?;
414
415 if !resp.status().is_success() {
416 return Err(status_err("LIST", prefix, resp.status(), resp.text().ok()));
417 }
418 let body = resp
419 .text()
420 .map_err(|e| io_err(&format!("LIST {prefix} body"), e))?;
421 let (page_entries, next_token) = parse_list_v2_detailed(&body);
422 entries.extend(page_entries);
423 if let Some(t) = next_token {
424 continuation_token = Some(t);
425 } else {
426 break;
427 }
428 }
429 Ok(entries)
430 }
431}
432
433fn check_status(resp: reqwest::blocking::Response, verb: &str, key: &str) -> Result<(), Error> {
434 if resp.status().is_success() {
435 Ok(())
436 } else {
437 let status = resp.status();
438 let body = resp.text().ok();
439 Err(status_err(verb, key, status, body))
440 }
441}
442
443fn status_err(verb: &str, key: &str, status: StatusCode, body: Option<String>) -> Error {
444 let snippet = body
445 .as_deref()
446 .map(|s| s.chars().take(200).collect::<String>())
447 .unwrap_or_default();
448 let msg = format!("{verb} {key} → {status} {snippet}");
449 match status {
450 StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(msg),
451 StatusCode::NOT_FOUND => Error::NotFound(msg),
452 _ => Error::Backend(msg),
453 }
454}
455
456fn parse_list_v2(body: &str) -> (Vec<String>, Option<String>) {
463 let keys = extract_all_tags(body, "Key");
464 let next = extract_first_tag(body, "NextContinuationToken");
465 let truncated = extract_first_tag(body, "IsTruncated")
466 .map(|v| v.trim().eq_ignore_ascii_case("true"))
467 .unwrap_or(false);
468 (keys, if truncated { next } else { None })
469}
470
471fn parse_list_v2_detailed(body: &str) -> (Vec<ObjectMeta>, Option<String>) {
478 let blocks = extract_all_tags(body, "Contents");
479 let entries = blocks
480 .into_iter()
481 .filter_map(|block| {
482 let key = extract_first_tag(&block, "Key")?;
483 let size = extract_first_tag(&block, "Size")?.trim().parse::<u64>().ok()?;
484 let last_modified = extract_first_tag(&block, "LastModified")?;
485 Some(ObjectMeta { key, size, last_modified })
486 })
487 .collect();
488 let next = extract_first_tag(body, "NextContinuationToken");
489 let truncated = extract_first_tag(body, "IsTruncated")
490 .map(|v| v.trim().eq_ignore_ascii_case("true"))
491 .unwrap_or(false);
492 (entries, if truncated { next } else { None })
493}
494
495fn extract_all_tags(body: &str, tag: &str) -> Vec<String> {
496 let open = format!("<{tag}>");
497 let close = format!("</{tag}>");
498 let mut out = Vec::new();
499 let mut search = body;
500 while let Some(start) = search.find(&open) {
501 let content_start = start + open.len();
502 if let Some(end) = search[content_start..].find(&close) {
503 out.push(search[content_start..content_start + end].to_string());
504 search = &search[content_start + end + close.len()..];
505 } else {
506 break;
507 }
508 }
509 out
510}
511
512fn extract_first_tag(body: &str, tag: &str) -> Option<String> {
513 extract_all_tags(body, tag).into_iter().next()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn parse_list_v2_extracts_keys() {
522 let body = r#"<?xml version="1.0" encoding="UTF-8"?>
523 <ListBucketResult>
524 <IsTruncated>false</IsTruncated>
525 <Contents><Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key></Contents>
526 <Contents><Key>yubaba/release-manifest.json</Key></Contents>
527 </ListBucketResult>"#;
528 let (keys, next) = parse_list_v2(body);
529 assert_eq!(
530 keys,
531 vec![
532 "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz".to_string(),
533 "yubaba/release-manifest.json".to_string(),
534 ]
535 );
536 assert!(next.is_none());
537 }
538
539 #[test]
540 fn parse_list_v2_returns_continuation_when_truncated() {
541 let body = r#"<ListBucketResult>
542 <IsTruncated>true</IsTruncated>
543 <NextContinuationToken>abc123</NextContinuationToken>
544 <Contents><Key>a</Key></Contents>
545 </ListBucketResult>"#;
546 let (keys, next) = parse_list_v2(body);
547 assert_eq!(keys, vec!["a".to_string()]);
548 assert_eq!(next.as_deref(), Some("abc123"));
549 }
550
551 #[test]
552 fn parse_list_v2_ignores_token_when_not_truncated() {
553 let body = r#"<ListBucketResult>
556 <IsTruncated>false</IsTruncated>
557 <NextContinuationToken>stale</NextContinuationToken>
558 <Contents><Key>a</Key></Contents>
559 </ListBucketResult>"#;
560 let (_, next) = parse_list_v2(body);
561 assert!(next.is_none());
562 }
563
564 #[test]
565 fn parse_list_v2_detailed_extracts_size_and_mtime() {
566 let body = r#"<?xml version="1.0" encoding="UTF-8"?>
567 <ListBucketResult>
568 <IsTruncated>false</IsTruncated>
569 <Contents>
570 <Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key>
571 <LastModified>2026-06-08T20:14:32.000Z</LastModified>
572 <ETag>"abc"</ETag>
573 <Size>4823104</Size>
574 <StorageClass>STANDARD</StorageClass>
575 </Contents>
576 <Contents>
577 <Key>yubaba/release-manifest.json</Key>
578 <LastModified>2026-06-08T20:14:35.000Z</LastModified>
579 <Size>412</Size>
580 </Contents>
581 </ListBucketResult>"#;
582 let (entries, next) = parse_list_v2_detailed(body);
583 assert_eq!(entries.len(), 2);
584 assert_eq!(entries[0].key, "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz");
585 assert_eq!(entries[0].size, 4823104);
586 assert_eq!(entries[0].last_modified, "2026-06-08T20:14:32.000Z");
587 assert_eq!(entries[1].key, "yubaba/release-manifest.json");
588 assert_eq!(entries[1].size, 412);
589 assert!(next.is_none());
590 }
591
592 #[test]
593 fn r2_object_store_constructs_with_explicit_keys() {
594 let s = R2ObjectStore::new("acct", "yah-dev", "AK", "SK").unwrap();
595 assert_eq!(s.object_url("k"), "https://acct.r2.cloudflarestorage.com/yah-dev/k");
596 assert_eq!(s.bucket_url(), "https://acct.r2.cloudflarestorage.com/yah-dev");
597 }
598
599 #[test]
600 fn object_url_preserves_slashes_in_key() {
601 let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
602 assert_eq!(
603 s.object_url("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"),
604 "https://acct.r2.cloudflarestorage.com/b/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"
605 );
606 }
607}