Skip to main content

yah_object_store/
r2.rs

1//! Cloudflare R2 implementation of [`ObjectStore`] over S3-compat SigV4.
2//!
3//! Uses `local_driver::s3_sign` helpers for signature computation and
4//! `reqwest::blocking` for HTTP so the [`ObjectStore`] trait stays synchronous.
5//! Async consumers wrap calls in `tokio::task::spawn_blocking`.
6//!
7//! ## Endpoint
8//!
9//! R2's S3-compat endpoint is `https://<account_id>.r2.cloudflarestorage.com`.
10//! Region is always `"auto"`. Bucket lives in the URL path:
11//! `https://<account_id>.r2.cloudflarestorage.com/<bucket>/<key>`.
12//!
13//! ## list_prefix
14//!
15//! Issues `GET /<bucket>?list-type=2&prefix=<encoded>` (ListObjectsV2) and
16//! parses the `<Key>…</Key>` elements out of the XML body. Continues with
17//! `&continuation-token=<…>` while `<IsTruncated>true</IsTruncated>` so
18//! prefixes larger than the 1000-key page size return complete.
19
20use 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
34/// R2's S3-compat region. The endpoint always accepts `"auto"`.
35const R2_REGION: &str = "auto";
36
37/// Keystore slot for the R2 S3 access key id.
38pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
39/// Keystore slot for the R2 S3 secret key.
40pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
41/// Env var fallback for the R2 access key id.
42pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
43/// Env var fallback for the R2 secret key.
44pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
45
46/// Percent-encoding set for query-string values. SigV4 requires
47/// unreserved characters (A-Z a-z 0-9 - _ . ~) to remain literal;
48/// everything else gets percent-encoded.
49const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
50    .remove(b'-')
51    .remove(b'_')
52    .remove(b'.')
53    .remove(b'~');
54
55/// Default content-type when the caller doesn't specify one.
56const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
57
58/// R2-backed [`ObjectStore`].
59///
60/// Construct with [`R2ObjectStore::new`] when keys are already in hand,
61/// or [`R2ObjectStore::from_vault`] to pull them from the yah keystore
62/// (with env-var fallback).
63pub 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    /// Construct with explicit keys.
73    ///
74    /// `account_id` is the Cloudflare account id (the subdomain in
75    /// `<account_id>.r2.cloudflarestorage.com`).
76    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    /// Construct from the yah keystore (vault), falling back to env vars.
96    ///
97    /// Reads `cloudflare-r2-access-key-id` / `cloudflare-r2-secret-key` slots
98    /// (env fallback `CF_R2_ACCESS_KEY_ID` / `CF_R2_SECRET_KEY`). Returns
99    /// [`Error::Auth`] if either is missing.
100    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
134/// Convert a reqwest error into our generic [`Error`].
135fn 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        // GET has no body: reqwest drops the `content-length: 0` header on the
171        // wire, so signing it (as `sign_s3_empty_body` does) yields a signature
172        // the server can't reproduce → 403 SignatureDoesNotMatch. Sign with the
173        // content-length-free helper instead, exactly like ListObjectsV2. The
174        // empty query string is correct for a plain object GET.
175        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        // HEAD is body-less like GET: sign without content-length (see `get`).
206        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            // S3 DELETE on a missing key returns 204 too — both are success
250            // semantics for an idempotent delete.
251            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        // Sign the same fixed header set as an unconditional PUT. The conditional
272        // header (If-Match / If-None-Match) is added *unsigned* afterwards: SigV4
273        // only covers the headers in `SignedHeaders`, and S3/R2 honor extra
274        // unsigned headers — so the precondition is enforced server-side without
275        // touching the signer.
276        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        // Prefer the ETag echoed in the PUT response; fall back to a HEAD if a
316        // backend ever omits it (R2 always returns it).
317        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        // HEAD is body-less: sign without content-length (see `head`).
328        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/// One `<Contents>` entry from an R2 `ListObjectsV2` response.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct ObjectMeta {
360    /// Object key (full path including any prefix).
361    pub key: String,
362    /// Object size in bytes.
363    pub size: u64,
364    /// Last-modified timestamp in ISO-8601 / RFC-3339 (R2's `<LastModified>` value).
365    pub last_modified: String,
366}
367
368impl R2ObjectStore {
369    /// List objects under `prefix` returning key + size + last-modified.
370    ///
371    /// Same paginated request as [`ObjectStore::list_prefix`] but parses the
372    /// `<Size>` and `<LastModified>` siblings of each `<Key>` element. Used by
373    /// the data-tab bucket viewer to render a directory-style listing.
374    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            // Canonical query MUST be sorted by parameter name (SigV4).
382            // Parameters: continuation-token (optional), list-type, prefix.
383            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
456/// Parse a `ListObjectsV2` XML response for keys + next continuation token.
457///
458/// Deliberately tiny — full XML parsing is overkill for the two elements we
459/// care about. Looks for `<Key>...</Key>` and `<NextContinuationToken>...`
460/// inside the body. If R2 ever changes the element shape (it won't — it's
461/// S3-compat), the integration test catches it.
462fn 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
471/// Parse `<Contents>` blocks for key + size + last-modified.
472///
473/// R2's `<Contents>` always has `<Key>` followed by `<LastModified>` and
474/// `<Size>` siblings. We walk `<Contents>...</Contents>` blocks and pull the
475/// three tags from each — order-insensitive within the block. Entries missing
476/// any of the three are skipped (defensive — R2 always emits all three).
477fn 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        // Some S3-compat impls emit NextContinuationToken with IsTruncated=false.
554        // We treat IsTruncated as load-bearing.
555        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}