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//!
20//! @yah:relay(R630, "Object-store correctness + tooling gaps surfaced by standing up the cr.yah.dev registry")
21//! @yah:at(2026-07-23T03:06:57Z)
22//! @yah:status(open)
23//! @yah:next("Both children were found while building yah-cr (the R2-backed OCI registry) on 2026-07-22 and are independent of that work — they are latent defects in shared object-store code that any caller can hit.")
24//! @yah:next("Start with the SigV4 child: it is a correctness bug that fails closed but silently constrains every key namespace we can use. The bucket-delete child is additive and can follow.")
25//! @yah:gotcha("The SigV4 defect is why cr.yah.dev stores OCI digests as sha256/<hex> instead of the natural sha256:<hex>. That workaround is load-bearing in two files that must stay in lockstep (app/yah/cli/src/cr.rs digest_key, app/yah/workers/yah-cr/src/index.ts digestKey). If the signing bug is fixed, those can be simplified — but only together, and only with a migration for keys already written.")
26//! @arch:see(.yah/docs/working/W175-per-publisher-prefix.md)
27//! @yah:handoff("Both children fixed and in review. Along the way two further latent defects in the same shared object-store code were found and fixed in-pass, both invisible to every existing test: (1) ObjectStore::delete signed DELETE with sign_s3_empty_body and so 403'd against R2 from the day it landed — which also means `yah cloud service prune` (static_asset_prune.rs) has never reclaimed anything; (2) the ListObjectsV2 parser returned raw XML text, so a key holding & came back as &amp; — harmless before, because such keys couldn't be written, and a broken round-trip the moment B1 made them writable.")
28//! @yah:gotcha("2026-08-25: the SigV4 defect is FIXED (R630-B1, in review), so the sha256/<hex> constraint this gotcha describes is lifted — but the workaround was deliberately left in place. Simplifying digest_key / digestKey to the natural sha256:<hex> means rewriting every blob key already in yah-cr-cache: a live-data migration on a running registry, operator-gated, not a drive-by. The two files still must stay in lockstep.")
29
30use std::time::Duration;
31
32use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
33use reqwest::blocking::Client;
34use reqwest::header::{HeaderValue, ETAG, IF_MATCH, IF_NONE_MATCH};
35use reqwest::StatusCode;
36use sha2::{Digest, Sha256};
37
38use local_driver::s3_sign::{
39    sign_s3_get_with_query, sign_s3_no_body, sign_s3_put_object, sign_s3_put_object_with,
40    uri_encode_key, S3PutOptions,
41};
42
43use crate::{Error, ObjectStore, Precondition};
44
45/// R2's S3-compat region. The endpoint always accepts `"auto"`.
46const R2_REGION: &str = "auto";
47
48/// Keystore slot for the R2 S3 access key id.
49pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
50/// Keystore slot for the R2 S3 secret key.
51pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
52/// Env var fallback for the R2 access key id.
53pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
54/// Env var fallback for the R2 secret key.
55pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
56
57/// Percent-encoding set for query-string values. SigV4 requires
58/// unreserved characters (A-Z a-z 0-9 - _ . ~) to remain literal;
59/// everything else gets percent-encoded.
60const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
61    .remove(b'-')
62    .remove(b'_')
63    .remove(b'.')
64    .remove(b'~');
65
66/// Default content-type for keys with no recognized extension.
67const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
68
69/// Content-type for an object key, inferred from its file extension.
70///
71/// R2 stores whatever Content-Type we send on PUT and serves it back verbatim
72/// (the CDN custom domain does no extension sniffing). An octet-stream default
73/// makes browsers *download* html shells instead of rendering them, so we set
74/// an explicit type for the extensions a static site actually ships. Unknown
75/// or extensionless keys (pointers, the `_yah-manifest.json` sidecar is `.json`
76/// and handled) fall back to [`DEFAULT_CONTENT_TYPE`].
77fn content_type_for_key(key: &str) -> &'static str {
78    let ext = match key.rsplit_once('.') {
79        // A `.` in a directory segment is not an extension.
80        Some((_, e)) if !e.contains('/') => e,
81        _ => "",
82    };
83    match ext.to_ascii_lowercase().as_str() {
84        "html" | "htm" => "text/html; charset=utf-8",
85        "css" => "text/css; charset=utf-8",
86        "js" | "mjs" => "text/javascript; charset=utf-8",
87        "json" | "map" => "application/json",
88        "webmanifest" => "application/manifest+json",
89        "xml" => "application/xml",
90        "txt" => "text/plain; charset=utf-8",
91        "svg" => "image/svg+xml",
92        "webp" => "image/webp",
93        "png" => "image/png",
94        "jpg" | "jpeg" => "image/jpeg",
95        "gif" => "image/gif",
96        "avif" => "image/avif",
97        "ico" => "image/x-icon",
98        "woff2" => "font/woff2",
99        "woff" => "font/woff",
100        "ttf" => "font/ttf",
101        "otf" => "font/otf",
102        "wasm" => "application/wasm",
103        "pdf" => "application/pdf",
104        _ => DEFAULT_CONTENT_TYPE,
105    }
106}
107
108/// R2-backed [`ObjectStore`].
109///
110/// Construct with [`R2ObjectStore::new`] when keys are already in hand,
111/// or [`R2ObjectStore::from_vault`] to pull them from the yah keystore
112/// (with env-var fallback).
113pub struct R2ObjectStore {
114    account_id: String,
115    bucket: String,
116    access_key: String,
117    secret_key: String,
118    /// Overrides the derived `https://<account_id>.r2.cloudflarestorage.com`.
119    /// See [`R2ObjectStore::with_endpoint`].
120    endpoint: Option<String>,
121    client: Option<Client>,
122}
123
124impl Drop for R2ObjectStore {
125    fn drop(&mut self) {
126        // `reqwest::blocking::Client` owns a background tokio runtime whose
127        // Drop panics with "Cannot drop a runtime in a context where blocking
128        // is not allowed" when the drop happens inside an async context. This
129        // fires when an `Arc<R2ObjectStore>` reaches zero from inside an
130        // awaited future (e.g. publish_to_r2). Detach the shutdown onto a
131        // fresh OS thread which has no tokio runtime context, so the client's
132        // Drop can shut its internal runtime down cleanly. Dep-neutral — this
133        // crate keeps its sync/tokio-free profile.
134        let Some(client) = self.client.take() else { return };
135        std::thread::spawn(move || drop(client));
136    }
137}
138
139impl R2ObjectStore {
140    /// Construct with explicit keys.
141    ///
142    /// `account_id` is the Cloudflare account id (the subdomain in
143    /// `<account_id>.r2.cloudflarestorage.com`).
144    pub fn new(
145        account_id: impl Into<String>,
146        bucket: impl Into<String>,
147        access_key: impl Into<String>,
148        secret_key: impl Into<String>,
149    ) -> Result<Self, Error> {
150        let client = Client::builder()
151            .timeout(Duration::from_secs(300))
152            .build()
153            .map_err(|e| Error::Backend(format!("reqwest client: {e}")))?;
154        Ok(Self {
155            account_id: account_id.into(),
156            bucket: bucket.into(),
157            access_key: access_key.into(),
158            secret_key: secret_key.into(),
159            endpoint: None,
160            client: Some(client),
161        })
162    }
163
164    /// Point this store at an S3-compatible endpoint other than R2 — in
165    /// practice, the pond tier's local MinIO (`http://127.0.0.1:9000`).
166    ///
167    /// Everything else about the store is already endpoint-agnostic: the bucket
168    /// lives in the URL path (path-style addressing, which MinIO also speaks)
169    /// and SigV4 is signed against whatever host the URL names.
170    ///
171    /// This exists because without it the pond rehearsal could not exercise the
172    /// *read* side of a publish at all. `publish_to_pond` uploads a directory
173    /// tree and offers no way to read an object back, so the one part of a
174    /// release that is a read-modify-write — the accumulating `index.json` that
175    /// https://yah.dev/releases renders from — was the one part a green local
176    /// rehearsal proved nothing about (R330-T32). A conditional-write loop that
177    /// has never run is a conditional-write loop you do not have.
178    ///
179    /// The region stays `"auto"`; MinIO accepts it.
180    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
181        let endpoint = endpoint.into();
182        let trimmed = endpoint.trim_end_matches('/');
183        self.endpoint = (!trimmed.is_empty()).then(|| trimmed.to_string());
184        self
185    }
186
187    fn client(&self) -> &Client {
188        self.client
189            .as_ref()
190            .expect("client is Some until Drop takes it")
191    }
192
193    /// Construct from the yah keystore (vault), falling back to env vars.
194    ///
195    /// Reads `cloudflare-r2-access-key-id` / `cloudflare-r2-secret-key` slots
196    /// (env fallback `CF_R2_ACCESS_KEY_ID` / `CF_R2_SECRET_KEY`). Returns
197    /// [`Error::Auth`] if either is missing.
198    pub fn from_vault(
199        account_id: impl Into<String>,
200        bucket: impl Into<String>,
201    ) -> Result<Self, Error> {
202        let access_key = fob::get_or_env(R2_ACCESS_KEY_SLOT, R2_ACCESS_KEY_ENV)
203            .map_err(|e| Error::Auth(format!("vault read {R2_ACCESS_KEY_SLOT}: {e}")))?
204            .ok_or_else(|| {
205                Error::Auth(format!(
206                    "missing R2 credential: set vault slot {R2_ACCESS_KEY_SLOT} or env {R2_ACCESS_KEY_ENV}"
207                ))
208            })?;
209        let secret_key = fob::get_or_env(R2_SECRET_KEY_SLOT, R2_SECRET_KEY_ENV)
210            .map_err(|e| Error::Auth(format!("vault read {R2_SECRET_KEY_SLOT}: {e}")))?
211            .ok_or_else(|| {
212                Error::Auth(format!(
213                    "missing R2 credential: set vault slot {R2_SECRET_KEY_SLOT} or env {R2_SECRET_KEY_ENV}"
214                ))
215            })?;
216        Self::new(account_id, bucket, access_key, secret_key)
217    }
218
219    fn endpoint(&self) -> String {
220        match &self.endpoint {
221            Some(e) => e.clone(),
222            None => format!("https://{}.r2.cloudflarestorage.com", self.account_id),
223        }
224    }
225
226    /// R630-B1: the key is AWS-`UriEncode`d here, at the one place a key becomes
227    /// a URL. Doing it here rather than in the signer is what makes the wire
228    /// path and the signed path the same bytes — see [`uri_encode_key`]. Before
229    /// this, a key holding `:` (an OCI digest `sha256:<hex>`, a timestamp) went
230    /// out raw, R2 canonicalized it per spec, and every request 403'd
231    /// `SignatureDoesNotMatch`.
232    fn object_url(&self, key: &str) -> String {
233        format!("{}/{}/{}", self.endpoint(), self.bucket, uri_encode_key(key))
234    }
235
236    fn bucket_url(&self) -> String {
237        format!("{}/{}", self.endpoint(), self.bucket)
238    }
239
240    /// The one PUT path, with `Cache-Control` optional (R703-B8).
241    ///
242    /// `put` and `put_cached` differ only in that header, so they share this
243    /// rather than each carrying their own signing + status handling — the
244    /// shape where one of two copies quietly stops matching the other.
245    fn put_inner(
246        &self,
247        key: &str,
248        data: Vec<u8>,
249        cache_control: Option<&str>,
250    ) -> Result<(), Error> {
251        let url = self.object_url(key);
252        let body_sha256 = {
253            let mut h = Sha256::new();
254            h.update(&data);
255            hex::encode(h.finalize())
256        };
257        let headers = sign_s3_put_object_with(
258            &url,
259            &body_sha256,
260            data.len(),
261            R2_REGION,
262            &self.access_key,
263            &self.secret_key,
264            &S3PutOptions {
265                content_type: content_type_for_key(key),
266                // Generic object-store put — the BLAKE3 stamp is a static-asset
267                // catalog concern, not a property of every object (R546-B10).
268                blake3_meta: None,
269                cache_control,
270            },
271        )
272        .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
273
274        let resp = self
275            .client()
276            .put(&url)
277            .headers(headers)
278            .body(data)
279            .send()
280            .map_err(|e| io_err(&format!("PUT {key}"), e))?;
281        check_status(resp, "PUT", key)
282    }
283}
284
285/// Convert a reqwest error into our generic [`Error`].
286fn io_err(ctx: &str, e: impl std::fmt::Display) -> Error {
287    Error::Io(format!("{ctx}: {e}"))
288}
289
290impl ObjectStore for R2ObjectStore {
291    fn locate(&self, key: &str) -> String {
292        self.object_url(key)
293    }
294
295    fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
296        self.put_inner(key, data, None)
297    }
298
299    fn put_cached(&self, key: &str, data: Vec<u8>, cache_control: &str) -> Result<(), Error> {
300        self.put_inner(key, data, Some(cache_control))
301    }
302
303    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
304        let url = self.object_url(key);
305        // GET has no body: reqwest drops the `content-length: 0` header on the
306        // wire, so signing it (as `sign_s3_empty_body` does) yields a signature
307        // the server can't reproduce → 403 SignatureDoesNotMatch. Sign with the
308        // content-length-free helper instead, exactly like ListObjectsV2. The
309        // empty query string is correct for a plain object GET.
310        let headers = sign_s3_get_with_query(
311            &url,
312            "",
313            R2_REGION,
314            &self.access_key,
315            &self.secret_key,
316        )
317        .map_err(|e| Error::Backend(format!("sign GET {key}: {e}")))?;
318
319        let resp = self
320            .client()
321            .get(&url)
322            .headers(headers)
323            .send()
324            .map_err(|e| io_err(&format!("GET {key}"), e))?;
325
326        match resp.status() {
327            StatusCode::OK => {
328                let bytes = resp
329                    .bytes()
330                    .map_err(|e| io_err(&format!("read GET {key}"), e))?;
331                Ok(Some(bytes.to_vec()))
332            }
333            StatusCode::NOT_FOUND => Ok(None),
334            s => Err(status_err("GET", key, s, resp.text().ok())),
335        }
336    }
337
338    fn head(&self, key: &str) -> Result<bool, Error> {
339        let url = self.object_url(key);
340        // HEAD is body-less like GET: sign without content-length (see `get`).
341        let headers = sign_s3_no_body(
342            "HEAD",
343            &url,
344            "",
345            R2_REGION,
346            &self.access_key,
347            &self.secret_key,
348        )
349        .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
350
351        let resp = self
352            .client()
353            .head(&url)
354            .headers(headers)
355            .send()
356            .map_err(|e| io_err(&format!("HEAD {key}"), e))?;
357
358        match resp.status() {
359            StatusCode::OK => Ok(true),
360            StatusCode::NOT_FOUND => Ok(false),
361            s => Err(status_err("HEAD", key, s, None)),
362        }
363    }
364
365    fn delete(&self, key: &str) -> Result<(), Error> {
366        let url = self.object_url(key);
367        // R630-F2: DELETE is body-less, so it signs like GET/HEAD and NOT with
368        // `sign_s3_empty_body` — reqwest strips the `content-length: 0` that
369        // signer puts in the canonical headers, R2 recomputes a different
370        // signature, and every DELETE comes back 403 SignatureDoesNotMatch.
371        // This code had never been run against live R2 (the trait tests use the
372        // in-memory store), so `ObjectStore::delete` was 100% broken on R2 from
373        // the day it landed. Confirmed live 2026-08-25, before and after.
374        let headers = sign_s3_no_body(
375            "DELETE",
376            &url,
377            "",
378            R2_REGION,
379            &self.access_key,
380            &self.secret_key,
381        )
382        .map_err(|e| Error::Backend(format!("sign DELETE {key}: {e}")))?;
383
384        let resp = self
385            .client()
386            .delete(&url)
387            .headers(headers)
388            .send()
389            .map_err(|e| io_err(&format!("DELETE {key}"), e))?;
390
391        match resp.status() {
392            // S3 DELETE on a missing key returns 204 too — both are success
393            // semantics for an idempotent delete.
394            StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
395            s => Err(status_err("DELETE", key, s, resp.text().ok())),
396        }
397    }
398
399    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
400        Ok(self
401            .list_prefix_detailed(prefix)?
402            .into_iter()
403            .map(|m| m.key)
404            .collect())
405    }
406
407    fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
408        let url = self.object_url(key);
409        let body_sha256 = {
410            let mut h = Sha256::new();
411            h.update(&data);
412            hex::encode(h.finalize())
413        };
414        // Sign the same fixed header set as an unconditional PUT. The conditional
415        // header (If-Match / If-None-Match) is added *unsigned* afterwards: SigV4
416        // only covers the headers in `SignedHeaders`, and S3/R2 honor extra
417        // unsigned headers — so the precondition is enforced server-side without
418        // touching the signer.
419        let mut headers = sign_s3_put_object(
420            &url,
421            &body_sha256,
422            content_type_for_key(key),
423            data.len(),
424            R2_REGION,
425            &self.access_key,
426            &self.secret_key,
427            None,
428        )
429        .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
430
431        match &cond {
432            Precondition::IfAbsent => {
433                headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*"));
434            }
435            Precondition::IfMatch(etag) => {
436                let v = HeaderValue::from_str(etag)
437                    .map_err(|e| Error::Backend(format!("invalid If-Match etag {etag:?}: {e}")))?;
438                headers.insert(IF_MATCH, v);
439            }
440        }
441
442        let resp = self
443            .client()
444            .put(&url)
445            .headers(headers)
446            .body(data)
447            .send()
448            .map_err(|e| io_err(&format!("PUT(if) {key}"), e))?;
449
450        let status = resp.status();
451        if status == StatusCode::PRECONDITION_FAILED {
452            return Err(Error::PreconditionFailed(format!(
453                "put_if {key}: precondition not met ({cond:?})"
454            )));
455        }
456        if !status.is_success() {
457            return Err(status_err("PUT(if)", key, status, resp.text().ok()));
458        }
459        // Prefer the ETag echoed in the PUT response; fall back to a HEAD if a
460        // backend ever omits it (R2 always returns it).
461        match resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) {
462            Some(e) => Ok(e.to_string()),
463            None => self
464                .etag(key)?
465                .ok_or_else(|| Error::Backend(format!("PUT(if) {key} returned no ETag"))),
466        }
467    }
468
469    fn etag(&self, key: &str) -> Result<Option<String>, Error> {
470        let url = self.object_url(key);
471        // HEAD is body-less: sign without content-length (see `head`).
472        let headers = sign_s3_no_body(
473            "HEAD",
474            &url,
475            "",
476            R2_REGION,
477            &self.access_key,
478            &self.secret_key,
479        )
480        .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
481
482        let resp = self
483            .client()
484            .head(&url)
485            .headers(headers)
486            .send()
487            .map_err(|e| io_err(&format!("HEAD(etag) {key}"), e))?;
488
489        match resp.status() {
490            StatusCode::OK => Ok(resp
491                .headers()
492                .get(ETAG)
493                .and_then(|v| v.to_str().ok())
494                .map(|s| s.to_string())),
495            StatusCode::NOT_FOUND => Ok(None),
496            s => Err(status_err("HEAD(etag)", key, s, None)),
497        }
498    }
499}
500
501/// One `<Contents>` entry from an R2 `ListObjectsV2` response.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct ObjectMeta {
504    /// Object key (full path including any prefix).
505    pub key: String,
506    /// Object size in bytes.
507    pub size: u64,
508    /// Last-modified timestamp in ISO-8601 / RFC-3339 (R2's `<LastModified>` value).
509    pub last_modified: String,
510}
511
512impl R2ObjectStore {
513    /// List objects under `prefix` returning key + size + last-modified.
514    ///
515    /// Same paginated request as [`ObjectStore::list_prefix`] but parses the
516    /// `<Size>` and `<LastModified>` siblings of each `<Key>` element. Used by
517    /// the data-tab bucket viewer to render a directory-style listing.
518    pub fn list_prefix_detailed(&self, prefix: &str) -> Result<Vec<ObjectMeta>, Error> {
519        let mut entries = Vec::new();
520        let mut continuation_token: Option<String> = None;
521        let bucket_url = self.bucket_url();
522        let encoded_prefix = utf8_percent_encode(prefix, QUERY_VALUE).to_string();
523
524        loop {
525            // Canonical query MUST be sorted by parameter name (SigV4).
526            // Parameters: continuation-token (optional), list-type, prefix.
527            let mut params: Vec<(String, String)> =
528                vec![("list-type".to_string(), "2".to_string())];
529            if let Some(token) = &continuation_token {
530                let encoded = utf8_percent_encode(token, QUERY_VALUE).to_string();
531                params.push(("continuation-token".to_string(), encoded));
532            }
533            params.push(("prefix".to_string(), encoded_prefix.clone()));
534            params.sort_by(|a, b| a.0.cmp(&b.0));
535            let canonical_query = params
536                .iter()
537                .map(|(k, v)| format!("{k}={v}"))
538                .collect::<Vec<_>>()
539                .join("&");
540
541            let url_with_query = format!("{bucket_url}?{canonical_query}");
542
543            let headers = sign_s3_get_with_query(
544                &bucket_url,
545                &canonical_query,
546                R2_REGION,
547                &self.access_key,
548                &self.secret_key,
549            )
550            .map_err(|e| Error::Backend(format!("sign LIST {prefix}: {e}")))?;
551
552            let resp = self
553                .client()
554                .get(&url_with_query)
555                .headers(headers)
556                .send()
557                .map_err(|e| io_err(&format!("LIST {prefix}"), e))?;
558
559            if !resp.status().is_success() {
560                return Err(status_err("LIST", prefix, resp.status(), resp.text().ok()));
561            }
562            let body = resp
563                .text()
564                .map_err(|e| io_err(&format!("LIST {prefix} body"), e))?;
565            let (page_entries, next_token) = parse_list_v2_detailed(&body);
566            entries.extend(page_entries);
567            if let Some(t) = next_token {
568                continuation_token = Some(t);
569            } else {
570                break;
571            }
572        }
573        Ok(entries)
574    }
575}
576
577fn check_status(resp: reqwest::blocking::Response, verb: &str, key: &str) -> Result<(), Error> {
578    if resp.status().is_success() {
579        Ok(())
580    } else {
581        let status = resp.status();
582        let body = resp.text().ok();
583        Err(status_err(verb, key, status, body))
584    }
585}
586
587fn status_err(verb: &str, key: &str, status: StatusCode, body: Option<String>) -> Error {
588    let snippet = body
589        .as_deref()
590        .map(|s| s.chars().take(200).collect::<String>())
591        .unwrap_or_default();
592    let msg = format!("{verb} {key} → {status} {snippet}");
593    match status {
594        StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(msg),
595        StatusCode::NOT_FOUND => Error::NotFound(msg),
596        _ => Error::Backend(msg),
597    }
598}
599
600/// Parse a `ListObjectsV2` XML response for keys + next continuation token.
601///
602/// Deliberately tiny — full XML parsing is overkill for the two elements we
603/// care about. Looks for `<Key>...</Key>` and `<NextContinuationToken>...`
604/// inside the body. If R2 ever changes the element shape (it won't — it's
605/// S3-compat), the integration test catches it.
606fn parse_list_v2(body: &str) -> (Vec<String>, Option<String>) {
607    let keys = extract_all_tags(body, "Key")
608        .iter()
609        .map(|k| decode_xml_entities(k))
610        .collect();
611    let next = extract_first_text(body, "NextContinuationToken");
612    let truncated = extract_first_tag(body, "IsTruncated")
613        .map(|v| v.trim().eq_ignore_ascii_case("true"))
614        .unwrap_or(false);
615    (keys, if truncated { next } else { None })
616}
617
618/// Parse `<Contents>` blocks for key + size + last-modified.
619///
620/// R2's `<Contents>` always has `<Key>` followed by `<LastModified>` and
621/// `<Size>` siblings. We walk `<Contents>...</Contents>` blocks and pull the
622/// three tags from each — order-insensitive within the block. Entries missing
623/// any of the three are skipped (defensive — R2 always emits all three).
624fn parse_list_v2_detailed(body: &str) -> (Vec<ObjectMeta>, Option<String>) {
625    let blocks = extract_all_tags(body, "Contents");
626    let entries = blocks
627        .into_iter()
628        .filter_map(|block| {
629            let key = extract_first_text(&block, "Key")?;
630            let size = extract_first_tag(&block, "Size")?.trim().parse::<u64>().ok()?;
631            let last_modified = extract_first_text(&block, "LastModified")?;
632            Some(ObjectMeta { key, size, last_modified })
633        })
634        .collect();
635    let next = extract_first_text(body, "NextContinuationToken");
636    let truncated = extract_first_tag(body, "IsTruncated")
637        .map(|v| v.trim().eq_ignore_ascii_case("true"))
638        .unwrap_or(false);
639    (entries, if truncated { next } else { None })
640}
641
642fn extract_all_tags(body: &str, tag: &str) -> Vec<String> {
643    let open = format!("<{tag}>");
644    let close = format!("</{tag}>");
645    let mut out = Vec::new();
646    let mut search = body;
647    while let Some(start) = search.find(&open) {
648        let content_start = start + open.len();
649        if let Some(end) = search[content_start..].find(&close) {
650            out.push(search[content_start..content_start + end].to_string());
651            search = &search[content_start + end + close.len()..];
652        } else {
653            break;
654        }
655    }
656    out
657}
658
659fn extract_first_tag(body: &str, tag: &str) -> Option<String> {
660    extract_all_tags(body, tag).into_iter().next()
661}
662
663/// Like [`extract_first_tag`] but for a LEAF element, whose text is XML-escaped.
664///
665/// Never use this on a container like `<Contents>` — decoding a block that
666/// still holds child tags would corrupt any `&amp;` that belongs to a nested
667/// value before the child tag is even extracted.
668fn extract_first_text(body: &str, tag: &str) -> Option<String> {
669    extract_first_tag(body, tag).map(|raw| decode_xml_entities(&raw))
670}
671
672/// Decode the XML 1.0 predefined entities plus numeric character references.
673///
674/// R630-B1 fallout: object keys go out on the wire percent-encoded but come
675/// back from `ListObjectsV2` as XML *text*, so a key holding `&` arrives as
676/// `&amp;` and one holding `<` as `&lt;`. Before the SigV4 fix those keys could
677/// not be written at all (they 403'd), so the raw-text read was never wrong in
678/// practice; now that they can be written, `list_prefix` would hand back a key
679/// that no subsequent `get`/`head`/`delete` can resolve. S3 also emits numeric
680/// references (`&#13;`) for control characters, which are legal in a key.
681///
682/// An unrecognized `&…` sequence is passed through verbatim rather than
683/// dropped — a literal `&` in a document that isn't escaping anything is not
684/// something to silently eat.
685fn decode_xml_entities(raw: &str) -> String {
686    if !raw.contains('&') {
687        return raw.to_string();
688    }
689    let mut out = String::with_capacity(raw.len());
690    let mut rest = raw;
691    while let Some(amp) = rest.find('&') {
692        out.push_str(&rest[..amp]);
693        let tail = &rest[amp..];
694        let Some(semi) = tail.find(';').filter(|&i| i <= 10) else {
695            out.push('&');
696            rest = &tail[1..];
697            continue;
698        };
699        let entity = &tail[1..semi];
700        let decoded = match entity {
701            "amp" => Some('&'),
702            "lt" => Some('<'),
703            "gt" => Some('>'),
704            "quot" => Some('"'),
705            "apos" => Some('\''),
706            _ => entity
707                .strip_prefix('#')
708                .and_then(|n| match n.strip_prefix(['x', 'X']) {
709                    Some(hex) => u32::from_str_radix(hex, 16).ok(),
710                    None => n.parse::<u32>().ok(),
711                })
712                .and_then(char::from_u32),
713        };
714        match decoded {
715            Some(c) => {
716                out.push(c);
717                rest = &tail[semi + 1..];
718            }
719            None => {
720                out.push('&');
721                rest = &tail[1..];
722            }
723        }
724    }
725    out.push_str(rest);
726    out
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn with_endpoint_redirects_every_url_and_leaves_r2_alone() {
735        let store = R2ObjectStore::new("acct", "yah-dev", "k", "s").unwrap();
736        assert_eq!(
737            store.object_url("yah/index.json"),
738            "https://acct.r2.cloudflarestorage.com/yah-dev/yah/index.json"
739        );
740
741        // Pond tier: same store, MinIO endpoint, path-style bucket preserved.
742        let pond = R2ObjectStore::new("pond", "yah-dev", "k", "s")
743            .unwrap()
744            .with_endpoint("http://127.0.0.1:9000");
745        assert_eq!(
746            pond.object_url("yah/index.json"),
747            "http://127.0.0.1:9000/yah-dev/yah/index.json"
748        );
749        assert_eq!(pond.bucket_url(), "http://127.0.0.1:9000/yah-dev");
750    }
751
752    #[test]
753    fn with_endpoint_normalizes_trailing_slash_and_ignores_empty() {
754        let s = R2ObjectStore::new("acct", "b", "k", "s")
755            .unwrap()
756            .with_endpoint("http://127.0.0.1:9000/");
757        assert_eq!(s.object_url("k1"), "http://127.0.0.1:9000/b/k1");
758        // An empty override is a config mistake, not an instruction to sign
759        // against the empty host — fall back to the derived R2 endpoint.
760        let s = R2ObjectStore::new("acct", "b", "k", "s")
761            .unwrap()
762            .with_endpoint("");
763        assert_eq!(
764            s.object_url("k1"),
765            "https://acct.r2.cloudflarestorage.com/b/k1"
766        );
767    }
768
769    /// Accept exactly one HTTP request on an ephemeral loopback port, answer
770    /// `200`, and hand the raw request head back. Enough of a server to prove
771    /// what went onto the wire, and no more — the point is the headers, and a
772    /// mock at the `reqwest` layer would only re-assert what the signer already
773    /// returned rather than what the client actually sent.
774    fn one_shot_http() -> (String, std::thread::JoinHandle<String>) {
775        use std::io::{BufRead, BufReader, Read, Write};
776
777        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
778        let url = format!("http://{}", listener.local_addr().unwrap());
779        let handle = std::thread::spawn(move || {
780            let (stream, _) = listener.accept().unwrap();
781            let mut reader = BufReader::new(stream);
782            let mut head = String::new();
783            loop {
784                let mut line = String::new();
785                if reader.read_line(&mut line).unwrap() == 0 {
786                    break;
787                }
788                let done = line == "\r\n";
789                head.push_str(&line);
790                if done {
791                    break;
792                }
793            }
794            // Drain the body, else the client sees the connection close
795            // mid-write and reports a broken pipe instead of our 200.
796            let len: usize = head
797                .lines()
798                .find_map(|l| {
799                    l.strip_prefix("content-length: ")
800                        .or_else(|| l.strip_prefix("Content-Length: "))
801                })
802                .and_then(|v| v.trim().parse().ok())
803                .unwrap_or(0);
804            let mut body = vec![0u8; len];
805            reader.read_exact(&mut body).unwrap();
806            reader
807                .into_inner()
808                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
809                .unwrap();
810            head
811        });
812        (url, handle)
813    }
814
815    /// R703-B8, the end of the chain: the signer builds a `Cache-Control` and
816    /// `reqwest` has to actually put it on the socket. Every other test here
817    /// stops at the `HeaderMap`, which is one `.headers()` call away from being
818    /// a test that passes while R2 stores an object with no directive.
819    #[test]
820    fn put_cached_sends_the_cache_control_header_on_the_wire() {
821        let (endpoint, server) = one_shot_http();
822        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
823            .unwrap()
824            .with_endpoint(endpoint);
825
826        store
827            .put_cached(
828                "yah-desktop/latest.json",
829                b"{\"version\":\"0.8.22\"}".to_vec(),
830                crate::CACHE_CONTROL_NO_CACHE,
831            )
832            .unwrap();
833
834        let head = server.join().unwrap().to_lowercase();
835        assert!(
836            head.starts_with("put /yah-dev/yah-desktop/latest.json "),
837            "{head}"
838        );
839        assert!(head.contains("cache-control: no-cache, max-age=0\r\n"), "{head}");
840        // Sent AND signed — an unsigned header R2 would reject the request over.
841        assert!(
842            head.contains("signedheaders=cache-control;content-length;content-type;host;"),
843            "{head}"
844        );
845    }
846
847    /// R630-B1, the property the whole fix rests on: the bytes on the wire and
848    /// the bytes in the SigV4 canonical request are the SAME bytes. Every other
849    /// test here stops at a `String` or a `HeaderMap`; only this one can catch
850    /// reqwest re-encoding (or decoding) the path between `object_url` and the
851    /// socket, which would put the signature back out of sync with the request
852    /// and give exactly the 403 this ticket is about.
853    #[test]
854    fn a_colon_key_goes_on_the_wire_percent_encoded() {
855        let (endpoint, server) = one_shot_http();
856        let store = R2ObjectStore::new("acct", "yah-cr", "AK", "SK")
857            .unwrap()
858            .with_endpoint(endpoint);
859
860        store
861            .put("blobs/sha256:deadbeef", b"layer".to_vec())
862            .unwrap();
863
864        let head = server.join().unwrap();
865        let request_line = head.lines().next().unwrap();
866        assert_eq!(
867            request_line, "PUT /yah-cr/blobs/sha256%3Adeadbeef HTTP/1.1",
868            "full head: {head}"
869        );
870        // Uppercase hex specifically — R2 canonicalizes with uppercase, so a
871        // lowercase `%3a` on the wire signs differently from what it computes.
872        assert!(!request_line.contains("%3a"), "{request_line}");
873    }
874
875    /// And the regression half on the wire: an ordinary key must reach the
876    /// socket byte-identical to before the encoding was introduced.
877    #[test]
878    fn an_unreserved_key_reaches_the_wire_unchanged() {
879        let (endpoint, server) = one_shot_http();
880        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
881            .unwrap()
882            .with_endpoint(endpoint);
883
884        store
885            .put("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz", b"x".to_vec())
886            .unwrap();
887
888        let head = server.join().unwrap();
889        assert_eq!(
890            head.lines().next().unwrap(),
891            "PUT /yah-dev/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz HTTP/1.1",
892            "full head: {head}"
893        );
894    }
895
896    /// R630-F2. `delete` signed with `sign_s3_empty_body` from the day it
897    /// landed, which puts `content-length: 0` inside the signature — and
898    /// reqwest strips that header off a body-less request, so R2 recomputed a
899    /// different canonical request and answered 403 for EVERY delete. The
900    /// in-memory `delete_is_idempotent` trait test passed throughout, because
901    /// it never touches this code. Assert against the socket instead.
902    #[test]
903    fn delete_signs_without_content_length_and_sends_none() {
904        let (endpoint, server) = one_shot_http();
905        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
906            .unwrap()
907            .with_endpoint(endpoint);
908
909        store.delete("some/blob.bin").unwrap();
910
911        let head = server.join().unwrap().to_lowercase();
912        assert!(head.starts_with("delete /yah-dev/some/blob.bin "), "{head}");
913        assert!(
914            head.contains("signedheaders=host;x-amz-content-sha256;x-amz-date"),
915            "content-length must NOT be signed on a body-less DELETE: {head}"
916        );
917        // And the header genuinely isn't on the wire — which is the whole
918        // reason signing it was fatal.
919        assert!(!head.contains("content-length"), "{head}");
920    }
921
922    /// The other half: a plain `put` must still send no directive at all. If it
923    /// quietly gained a default, versioned release bytes would start carrying
924    /// whatever that default was.
925    #[test]
926    fn a_plain_put_sends_no_cache_control_header() {
927        let (endpoint, server) = one_shot_http();
928        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
929            .unwrap()
930            .with_endpoint(endpoint);
931
932        store.put("some/blob.bin", b"bytes".to_vec()).unwrap();
933
934        let head = server.join().unwrap().to_lowercase();
935        assert!(!head.contains("cache-control"), "{head}");
936    }
937
938    #[test]
939    fn parse_list_v2_extracts_keys() {
940        let body = r#"<?xml version="1.0" encoding="UTF-8"?>
941            <ListBucketResult>
942                <IsTruncated>false</IsTruncated>
943                <Contents><Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key></Contents>
944                <Contents><Key>yubaba/release-manifest.json</Key></Contents>
945            </ListBucketResult>"#;
946        let (keys, next) = parse_list_v2(body);
947        assert_eq!(
948            keys,
949            vec![
950                "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz".to_string(),
951                "yubaba/release-manifest.json".to_string(),
952            ]
953        );
954        assert!(next.is_none());
955    }
956
957    #[test]
958    fn parse_list_v2_returns_continuation_when_truncated() {
959        let body = r#"<ListBucketResult>
960                <IsTruncated>true</IsTruncated>
961                <NextContinuationToken>abc123</NextContinuationToken>
962                <Contents><Key>a</Key></Contents>
963            </ListBucketResult>"#;
964        let (keys, next) = parse_list_v2(body);
965        assert_eq!(keys, vec!["a".to_string()]);
966        assert_eq!(next.as_deref(), Some("abc123"));
967    }
968
969    #[test]
970    fn parse_list_v2_ignores_token_when_not_truncated() {
971        // Some S3-compat impls emit NextContinuationToken with IsTruncated=false.
972        // We treat IsTruncated as load-bearing.
973        let body = r#"<ListBucketResult>
974                <IsTruncated>false</IsTruncated>
975                <NextContinuationToken>stale</NextContinuationToken>
976                <Contents><Key>a</Key></Contents>
977            </ListBucketResult>"#;
978        let (_, next) = parse_list_v2(body);
979        assert!(next.is_none());
980    }
981
982    #[test]
983    fn parse_list_v2_detailed_extracts_size_and_mtime() {
984        let body = r#"<?xml version="1.0" encoding="UTF-8"?>
985            <ListBucketResult>
986                <IsTruncated>false</IsTruncated>
987                <Contents>
988                    <Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key>
989                    <LastModified>2026-06-08T20:14:32.000Z</LastModified>
990                    <ETag>"abc"</ETag>
991                    <Size>4823104</Size>
992                    <StorageClass>STANDARD</StorageClass>
993                </Contents>
994                <Contents>
995                    <Key>yubaba/release-manifest.json</Key>
996                    <LastModified>2026-06-08T20:14:35.000Z</LastModified>
997                    <Size>412</Size>
998                </Contents>
999            </ListBucketResult>"#;
1000        let (entries, next) = parse_list_v2_detailed(body);
1001        assert_eq!(entries.len(), 2);
1002        assert_eq!(entries[0].key, "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz");
1003        assert_eq!(entries[0].size, 4823104);
1004        assert_eq!(entries[0].last_modified, "2026-06-08T20:14:32.000Z");
1005        assert_eq!(entries[1].key, "yubaba/release-manifest.json");
1006        assert_eq!(entries[1].size, 412);
1007        assert!(next.is_none());
1008    }
1009
1010    #[test]
1011    fn r2_object_store_constructs_with_explicit_keys() {
1012        let s = R2ObjectStore::new("acct", "yah-dev", "AK", "SK").unwrap();
1013        assert_eq!(s.object_url("k"), "https://acct.r2.cloudflarestorage.com/yah-dev/k");
1014        assert_eq!(s.bucket_url(), "https://acct.r2.cloudflarestorage.com/yah-dev");
1015    }
1016
1017    #[test]
1018    fn object_url_preserves_slashes_in_key() {
1019        let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
1020        assert_eq!(
1021            s.object_url("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"),
1022            "https://acct.r2.cloudflarestorage.com/b/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"
1023        );
1024    }
1025
1026    /// R630-B1 fallout. A key holding `&` could not be written before the SigV4
1027    /// fix (it 403'd like every other non-unreserved key), so the raw-XML read
1028    /// was never observably wrong. Now that it CAN be written, a `list_prefix`
1029    /// that hands back `a&amp;b` names an object no `get`/`head`/`delete` can
1030    /// resolve — the round-trip would break at the far end instead of the near
1031    /// one.
1032    #[test]
1033    fn list_keys_are_xml_decoded() {
1034        let body = "<ListBucketResult>\
1035            <Contents><Key>a&amp;b.json</Key><Size>1</Size><LastModified>t</LastModified></Contents>\
1036            <Contents><Key>x&lt;y&gt;z</Key><Size>2</Size><LastModified>t</LastModified></Contents>\
1037            <Contents><Key>q&quot;r&apos;s</Key><Size>3</Size><LastModified>t</LastModified></Contents>\
1038            <Contents><Key>n&#13;m&#x41;</Key><Size>4</Size><LastModified>t</LastModified></Contents>\
1039            <IsTruncated>false</IsTruncated></ListBucketResult>";
1040        let (keys, next) = parse_list_v2(body);
1041        assert_eq!(keys, vec!["a&b.json", "x<y>z", "q\"r's", "n\rmA"]);
1042        assert!(next.is_none());
1043        // The detailed form shares the decode, and its `<Contents>` block walk
1044        // must still see the child tags — decoding the block first would break it.
1045        let (entries, _) = parse_list_v2_detailed(body);
1046        assert_eq!(entries.len(), 4);
1047        assert_eq!(entries[0].key, "a&b.json");
1048        assert_eq!(entries[0].size, 1);
1049    }
1050
1051    /// A lone `&` that isn't opening an entity is passed through, not eaten.
1052    #[test]
1053    fn xml_decode_passes_through_a_non_entity_ampersand() {
1054        assert_eq!(decode_xml_entities("a & b"), "a & b");
1055        assert_eq!(decode_xml_entities("&notanentity;"), "&notanentity;");
1056        assert_eq!(decode_xml_entities("plain/key.json"), "plain/key.json");
1057        assert_eq!(decode_xml_entities("&amp;&amp;"), "&&");
1058    }
1059
1060    /// R630-B1. The key becomes a URL exactly here, so this is the one place
1061    /// the AWS encoding can be applied such that the wire path and the SigV4
1062    /// canonical path agree. A `sha256:<hex>` OCI digest is the key shape that
1063    /// forced the issue — cr.yah.dev writes `sha256/<hex>` today purely to dodge
1064    /// it (see the R630 relay gotcha).
1065    #[test]
1066    fn object_url_encodes_a_colon_in_the_key() {
1067        let s = R2ObjectStore::new("acct", "yah-cr", "AK", "SK").unwrap();
1068        assert_eq!(
1069            s.object_url("blobs/sha256:deadbeef"),
1070            "https://acct.r2.cloudflarestorage.com/yah-cr/blobs/sha256%3Adeadbeef"
1071        );
1072        // `locate` shares the choke point, so a caller handed the URL for a
1073        // direct fetch gets the encoded form too.
1074        assert_eq!(s.locate("blobs/sha256:deadbeef"), s.object_url("blobs/sha256:deadbeef"));
1075    }
1076
1077    /// Regression guard: today's keys must produce byte-identical URLs, or the
1078    /// fix for the colon case silently 403s everything that already works.
1079    #[test]
1080    fn object_url_leaves_unreserved_keys_untouched() {
1081        let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
1082        for key in [
1083            "k",
1084            "yah/index.json",
1085            "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz",
1086            "releases/v1.2.3-rc.1/yah_1.2.3_aarch64.dmg",
1087        ] {
1088            assert_eq!(
1089                s.object_url(key),
1090                format!("https://acct.r2.cloudflarestorage.com/b/{key}")
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn content_type_inferred_from_extension() {
1097        assert_eq!(
1098            content_type_for_key("yah-marketing/cloud/index.html"),
1099            "text/html; charset=utf-8"
1100        );
1101        assert_eq!(content_type_for_key("app.css"), "text/css; charset=utf-8");
1102        assert_eq!(content_type_for_key("bundle.mjs"), "text/javascript; charset=utf-8");
1103        assert_eq!(content_type_for_key("illustrations/horse.webp"), "image/webp");
1104        assert_eq!(content_type_for_key("manifest.json"), "application/json");
1105        // Extensionless keys (pointers) and dotted directory segments fall back.
1106        assert_eq!(content_type_for_key("pointers/releases"), DEFAULT_CONTENT_TYPE);
1107        assert_eq!(content_type_for_key("v1.2/binary"), DEFAULT_CONTENT_TYPE);
1108    }
1109}