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
28use std::time::Duration;
29
30use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
31use reqwest::blocking::Client;
32use reqwest::header::{HeaderValue, ETAG, IF_MATCH, IF_NONE_MATCH};
33use reqwest::StatusCode;
34use sha2::{Digest, Sha256};
35
36use local_driver::s3_sign::{
37    sign_s3_empty_body, sign_s3_get_with_query, sign_s3_no_body, sign_s3_put_object,
38    sign_s3_put_object_with, S3PutOptions,
39};
40
41use crate::{Error, ObjectStore, Precondition};
42
43/// R2's S3-compat region. The endpoint always accepts `"auto"`.
44const R2_REGION: &str = "auto";
45
46/// Keystore slot for the R2 S3 access key id.
47pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
48/// Keystore slot for the R2 S3 secret key.
49pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
50/// Env var fallback for the R2 access key id.
51pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
52/// Env var fallback for the R2 secret key.
53pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
54
55/// Percent-encoding set for query-string values. SigV4 requires
56/// unreserved characters (A-Z a-z 0-9 - _ . ~) to remain literal;
57/// everything else gets percent-encoded.
58const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
59    .remove(b'-')
60    .remove(b'_')
61    .remove(b'.')
62    .remove(b'~');
63
64/// Default content-type for keys with no recognized extension.
65const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
66
67/// Content-type for an object key, inferred from its file extension.
68///
69/// R2 stores whatever Content-Type we send on PUT and serves it back verbatim
70/// (the CDN custom domain does no extension sniffing). An octet-stream default
71/// makes browsers *download* html shells instead of rendering them, so we set
72/// an explicit type for the extensions a static site actually ships. Unknown
73/// or extensionless keys (pointers, the `_yah-manifest.json` sidecar is `.json`
74/// and handled) fall back to [`DEFAULT_CONTENT_TYPE`].
75fn content_type_for_key(key: &str) -> &'static str {
76    let ext = match key.rsplit_once('.') {
77        // A `.` in a directory segment is not an extension.
78        Some((_, e)) if !e.contains('/') => e,
79        _ => "",
80    };
81    match ext.to_ascii_lowercase().as_str() {
82        "html" | "htm" => "text/html; charset=utf-8",
83        "css" => "text/css; charset=utf-8",
84        "js" | "mjs" => "text/javascript; charset=utf-8",
85        "json" | "map" => "application/json",
86        "webmanifest" => "application/manifest+json",
87        "xml" => "application/xml",
88        "txt" => "text/plain; charset=utf-8",
89        "svg" => "image/svg+xml",
90        "webp" => "image/webp",
91        "png" => "image/png",
92        "jpg" | "jpeg" => "image/jpeg",
93        "gif" => "image/gif",
94        "avif" => "image/avif",
95        "ico" => "image/x-icon",
96        "woff2" => "font/woff2",
97        "woff" => "font/woff",
98        "ttf" => "font/ttf",
99        "otf" => "font/otf",
100        "wasm" => "application/wasm",
101        "pdf" => "application/pdf",
102        _ => DEFAULT_CONTENT_TYPE,
103    }
104}
105
106/// R2-backed [`ObjectStore`].
107///
108/// Construct with [`R2ObjectStore::new`] when keys are already in hand,
109/// or [`R2ObjectStore::from_vault`] to pull them from the yah keystore
110/// (with env-var fallback).
111pub struct R2ObjectStore {
112    account_id: String,
113    bucket: String,
114    access_key: String,
115    secret_key: String,
116    /// Overrides the derived `https://<account_id>.r2.cloudflarestorage.com`.
117    /// See [`R2ObjectStore::with_endpoint`].
118    endpoint: Option<String>,
119    client: Option<Client>,
120}
121
122impl Drop for R2ObjectStore {
123    fn drop(&mut self) {
124        // `reqwest::blocking::Client` owns a background tokio runtime whose
125        // Drop panics with "Cannot drop a runtime in a context where blocking
126        // is not allowed" when the drop happens inside an async context. This
127        // fires when an `Arc<R2ObjectStore>` reaches zero from inside an
128        // awaited future (e.g. publish_to_r2). Detach the shutdown onto a
129        // fresh OS thread which has no tokio runtime context, so the client's
130        // Drop can shut its internal runtime down cleanly. Dep-neutral — this
131        // crate keeps its sync/tokio-free profile.
132        let Some(client) = self.client.take() else { return };
133        std::thread::spawn(move || drop(client));
134    }
135}
136
137impl R2ObjectStore {
138    /// Construct with explicit keys.
139    ///
140    /// `account_id` is the Cloudflare account id (the subdomain in
141    /// `<account_id>.r2.cloudflarestorage.com`).
142    pub fn new(
143        account_id: impl Into<String>,
144        bucket: impl Into<String>,
145        access_key: impl Into<String>,
146        secret_key: impl Into<String>,
147    ) -> Result<Self, Error> {
148        let client = Client::builder()
149            .timeout(Duration::from_secs(300))
150            .build()
151            .map_err(|e| Error::Backend(format!("reqwest client: {e}")))?;
152        Ok(Self {
153            account_id: account_id.into(),
154            bucket: bucket.into(),
155            access_key: access_key.into(),
156            secret_key: secret_key.into(),
157            endpoint: None,
158            client: Some(client),
159        })
160    }
161
162    /// Point this store at an S3-compatible endpoint other than R2 — in
163    /// practice, the pond tier's local MinIO (`http://127.0.0.1:9000`).
164    ///
165    /// Everything else about the store is already endpoint-agnostic: the bucket
166    /// lives in the URL path (path-style addressing, which MinIO also speaks)
167    /// and SigV4 is signed against whatever host the URL names.
168    ///
169    /// This exists because without it the pond rehearsal could not exercise the
170    /// *read* side of a publish at all. `publish_to_pond` uploads a directory
171    /// tree and offers no way to read an object back, so the one part of a
172    /// release that is a read-modify-write — the accumulating `index.json` that
173    /// https://yah.dev/releases renders from — was the one part a green local
174    /// rehearsal proved nothing about (R330-T32). A conditional-write loop that
175    /// has never run is a conditional-write loop you do not have.
176    ///
177    /// The region stays `"auto"`; MinIO accepts it.
178    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
179        let endpoint = endpoint.into();
180        let trimmed = endpoint.trim_end_matches('/');
181        self.endpoint = (!trimmed.is_empty()).then(|| trimmed.to_string());
182        self
183    }
184
185    fn client(&self) -> &Client {
186        self.client
187            .as_ref()
188            .expect("client is Some until Drop takes it")
189    }
190
191    /// Construct from the yah keystore (vault), falling back to env vars.
192    ///
193    /// Reads `cloudflare-r2-access-key-id` / `cloudflare-r2-secret-key` slots
194    /// (env fallback `CF_R2_ACCESS_KEY_ID` / `CF_R2_SECRET_KEY`). Returns
195    /// [`Error::Auth`] if either is missing.
196    pub fn from_vault(
197        account_id: impl Into<String>,
198        bucket: impl Into<String>,
199    ) -> Result<Self, Error> {
200        let access_key = fob::get_or_env(R2_ACCESS_KEY_SLOT, R2_ACCESS_KEY_ENV)
201            .map_err(|e| Error::Auth(format!("vault read {R2_ACCESS_KEY_SLOT}: {e}")))?
202            .ok_or_else(|| {
203                Error::Auth(format!(
204                    "missing R2 credential: set vault slot {R2_ACCESS_KEY_SLOT} or env {R2_ACCESS_KEY_ENV}"
205                ))
206            })?;
207        let secret_key = fob::get_or_env(R2_SECRET_KEY_SLOT, R2_SECRET_KEY_ENV)
208            .map_err(|e| Error::Auth(format!("vault read {R2_SECRET_KEY_SLOT}: {e}")))?
209            .ok_or_else(|| {
210                Error::Auth(format!(
211                    "missing R2 credential: set vault slot {R2_SECRET_KEY_SLOT} or env {R2_SECRET_KEY_ENV}"
212                ))
213            })?;
214        Self::new(account_id, bucket, access_key, secret_key)
215    }
216
217    fn endpoint(&self) -> String {
218        match &self.endpoint {
219            Some(e) => e.clone(),
220            None => format!("https://{}.r2.cloudflarestorage.com", self.account_id),
221        }
222    }
223
224    fn object_url(&self, key: &str) -> String {
225        format!("{}/{}/{}", self.endpoint(), self.bucket, key)
226    }
227
228    fn bucket_url(&self) -> String {
229        format!("{}/{}", self.endpoint(), self.bucket)
230    }
231
232    /// The one PUT path, with `Cache-Control` optional (R703-B8).
233    ///
234    /// `put` and `put_cached` differ only in that header, so they share this
235    /// rather than each carrying their own signing + status handling — the
236    /// shape where one of two copies quietly stops matching the other.
237    fn put_inner(
238        &self,
239        key: &str,
240        data: Vec<u8>,
241        cache_control: Option<&str>,
242    ) -> Result<(), Error> {
243        let url = self.object_url(key);
244        let body_sha256 = {
245            let mut h = Sha256::new();
246            h.update(&data);
247            hex::encode(h.finalize())
248        };
249        let headers = sign_s3_put_object_with(
250            &url,
251            &body_sha256,
252            data.len(),
253            R2_REGION,
254            &self.access_key,
255            &self.secret_key,
256            &S3PutOptions {
257                content_type: content_type_for_key(key),
258                // Generic object-store put — the BLAKE3 stamp is a static-asset
259                // catalog concern, not a property of every object (R546-B10).
260                blake3_meta: None,
261                cache_control,
262            },
263        )
264        .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
265
266        let resp = self
267            .client()
268            .put(&url)
269            .headers(headers)
270            .body(data)
271            .send()
272            .map_err(|e| io_err(&format!("PUT {key}"), e))?;
273        check_status(resp, "PUT", key)
274    }
275}
276
277/// Convert a reqwest error into our generic [`Error`].
278fn io_err(ctx: &str, e: impl std::fmt::Display) -> Error {
279    Error::Io(format!("{ctx}: {e}"))
280}
281
282impl ObjectStore for R2ObjectStore {
283    fn locate(&self, key: &str) -> String {
284        self.object_url(key)
285    }
286
287    fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
288        self.put_inner(key, data, None)
289    }
290
291    fn put_cached(&self, key: &str, data: Vec<u8>, cache_control: &str) -> Result<(), Error> {
292        self.put_inner(key, data, Some(cache_control))
293    }
294
295    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
296        let url = self.object_url(key);
297        // GET has no body: reqwest drops the `content-length: 0` header on the
298        // wire, so signing it (as `sign_s3_empty_body` does) yields a signature
299        // the server can't reproduce → 403 SignatureDoesNotMatch. Sign with the
300        // content-length-free helper instead, exactly like ListObjectsV2. The
301        // empty query string is correct for a plain object GET.
302        let headers = sign_s3_get_with_query(
303            &url,
304            "",
305            R2_REGION,
306            &self.access_key,
307            &self.secret_key,
308        )
309        .map_err(|e| Error::Backend(format!("sign GET {key}: {e}")))?;
310
311        let resp = self
312            .client()
313            .get(&url)
314            .headers(headers)
315            .send()
316            .map_err(|e| io_err(&format!("GET {key}"), e))?;
317
318        match resp.status() {
319            StatusCode::OK => {
320                let bytes = resp
321                    .bytes()
322                    .map_err(|e| io_err(&format!("read GET {key}"), e))?;
323                Ok(Some(bytes.to_vec()))
324            }
325            StatusCode::NOT_FOUND => Ok(None),
326            s => Err(status_err("GET", key, s, resp.text().ok())),
327        }
328    }
329
330    fn head(&self, key: &str) -> Result<bool, Error> {
331        let url = self.object_url(key);
332        // HEAD is body-less like GET: sign without content-length (see `get`).
333        let headers = sign_s3_no_body(
334            "HEAD",
335            &url,
336            "",
337            R2_REGION,
338            &self.access_key,
339            &self.secret_key,
340        )
341        .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
342
343        let resp = self
344            .client()
345            .head(&url)
346            .headers(headers)
347            .send()
348            .map_err(|e| io_err(&format!("HEAD {key}"), e))?;
349
350        match resp.status() {
351            StatusCode::OK => Ok(true),
352            StatusCode::NOT_FOUND => Ok(false),
353            s => Err(status_err("HEAD", key, s, None)),
354        }
355    }
356
357    fn delete(&self, key: &str) -> Result<(), Error> {
358        let url = self.object_url(key);
359        let headers = sign_s3_empty_body(
360            "DELETE",
361            &url,
362            R2_REGION,
363            &self.access_key,
364            &self.secret_key,
365        )
366        .map_err(|e| Error::Backend(format!("sign DELETE {key}: {e}")))?;
367
368        let resp = self
369            .client()
370            .delete(&url)
371            .headers(headers)
372            .send()
373            .map_err(|e| io_err(&format!("DELETE {key}"), e))?;
374
375        match resp.status() {
376            // S3 DELETE on a missing key returns 204 too — both are success
377            // semantics for an idempotent delete.
378            StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
379            s => Err(status_err("DELETE", key, s, resp.text().ok())),
380        }
381    }
382
383    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
384        Ok(self
385            .list_prefix_detailed(prefix)?
386            .into_iter()
387            .map(|m| m.key)
388            .collect())
389    }
390
391    fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
392        let url = self.object_url(key);
393        let body_sha256 = {
394            let mut h = Sha256::new();
395            h.update(&data);
396            hex::encode(h.finalize())
397        };
398        // Sign the same fixed header set as an unconditional PUT. The conditional
399        // header (If-Match / If-None-Match) is added *unsigned* afterwards: SigV4
400        // only covers the headers in `SignedHeaders`, and S3/R2 honor extra
401        // unsigned headers — so the precondition is enforced server-side without
402        // touching the signer.
403        let mut headers = sign_s3_put_object(
404            &url,
405            &body_sha256,
406            content_type_for_key(key),
407            data.len(),
408            R2_REGION,
409            &self.access_key,
410            &self.secret_key,
411            None,
412        )
413        .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
414
415        match &cond {
416            Precondition::IfAbsent => {
417                headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*"));
418            }
419            Precondition::IfMatch(etag) => {
420                let v = HeaderValue::from_str(etag)
421                    .map_err(|e| Error::Backend(format!("invalid If-Match etag {etag:?}: {e}")))?;
422                headers.insert(IF_MATCH, v);
423            }
424        }
425
426        let resp = self
427            .client()
428            .put(&url)
429            .headers(headers)
430            .body(data)
431            .send()
432            .map_err(|e| io_err(&format!("PUT(if) {key}"), e))?;
433
434        let status = resp.status();
435        if status == StatusCode::PRECONDITION_FAILED {
436            return Err(Error::PreconditionFailed(format!(
437                "put_if {key}: precondition not met ({cond:?})"
438            )));
439        }
440        if !status.is_success() {
441            return Err(status_err("PUT(if)", key, status, resp.text().ok()));
442        }
443        // Prefer the ETag echoed in the PUT response; fall back to a HEAD if a
444        // backend ever omits it (R2 always returns it).
445        match resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) {
446            Some(e) => Ok(e.to_string()),
447            None => self
448                .etag(key)?
449                .ok_or_else(|| Error::Backend(format!("PUT(if) {key} returned no ETag"))),
450        }
451    }
452
453    fn etag(&self, key: &str) -> Result<Option<String>, Error> {
454        let url = self.object_url(key);
455        // HEAD is body-less: sign without content-length (see `head`).
456        let headers = sign_s3_no_body(
457            "HEAD",
458            &url,
459            "",
460            R2_REGION,
461            &self.access_key,
462            &self.secret_key,
463        )
464        .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
465
466        let resp = self
467            .client()
468            .head(&url)
469            .headers(headers)
470            .send()
471            .map_err(|e| io_err(&format!("HEAD(etag) {key}"), e))?;
472
473        match resp.status() {
474            StatusCode::OK => Ok(resp
475                .headers()
476                .get(ETAG)
477                .and_then(|v| v.to_str().ok())
478                .map(|s| s.to_string())),
479            StatusCode::NOT_FOUND => Ok(None),
480            s => Err(status_err("HEAD(etag)", key, s, None)),
481        }
482    }
483}
484
485/// One `<Contents>` entry from an R2 `ListObjectsV2` response.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct ObjectMeta {
488    /// Object key (full path including any prefix).
489    pub key: String,
490    /// Object size in bytes.
491    pub size: u64,
492    /// Last-modified timestamp in ISO-8601 / RFC-3339 (R2's `<LastModified>` value).
493    pub last_modified: String,
494}
495
496impl R2ObjectStore {
497    /// List objects under `prefix` returning key + size + last-modified.
498    ///
499    /// Same paginated request as [`ObjectStore::list_prefix`] but parses the
500    /// `<Size>` and `<LastModified>` siblings of each `<Key>` element. Used by
501    /// the data-tab bucket viewer to render a directory-style listing.
502    pub fn list_prefix_detailed(&self, prefix: &str) -> Result<Vec<ObjectMeta>, Error> {
503        let mut entries = Vec::new();
504        let mut continuation_token: Option<String> = None;
505        let bucket_url = self.bucket_url();
506        let encoded_prefix = utf8_percent_encode(prefix, QUERY_VALUE).to_string();
507
508        loop {
509            // Canonical query MUST be sorted by parameter name (SigV4).
510            // Parameters: continuation-token (optional), list-type, prefix.
511            let mut params: Vec<(String, String)> =
512                vec![("list-type".to_string(), "2".to_string())];
513            if let Some(token) = &continuation_token {
514                let encoded = utf8_percent_encode(token, QUERY_VALUE).to_string();
515                params.push(("continuation-token".to_string(), encoded));
516            }
517            params.push(("prefix".to_string(), encoded_prefix.clone()));
518            params.sort_by(|a, b| a.0.cmp(&b.0));
519            let canonical_query = params
520                .iter()
521                .map(|(k, v)| format!("{k}={v}"))
522                .collect::<Vec<_>>()
523                .join("&");
524
525            let url_with_query = format!("{bucket_url}?{canonical_query}");
526
527            let headers = sign_s3_get_with_query(
528                &bucket_url,
529                &canonical_query,
530                R2_REGION,
531                &self.access_key,
532                &self.secret_key,
533            )
534            .map_err(|e| Error::Backend(format!("sign LIST {prefix}: {e}")))?;
535
536            let resp = self
537                .client()
538                .get(&url_with_query)
539                .headers(headers)
540                .send()
541                .map_err(|e| io_err(&format!("LIST {prefix}"), e))?;
542
543            if !resp.status().is_success() {
544                return Err(status_err("LIST", prefix, resp.status(), resp.text().ok()));
545            }
546            let body = resp
547                .text()
548                .map_err(|e| io_err(&format!("LIST {prefix} body"), e))?;
549            let (page_entries, next_token) = parse_list_v2_detailed(&body);
550            entries.extend(page_entries);
551            if let Some(t) = next_token {
552                continuation_token = Some(t);
553            } else {
554                break;
555            }
556        }
557        Ok(entries)
558    }
559}
560
561fn check_status(resp: reqwest::blocking::Response, verb: &str, key: &str) -> Result<(), Error> {
562    if resp.status().is_success() {
563        Ok(())
564    } else {
565        let status = resp.status();
566        let body = resp.text().ok();
567        Err(status_err(verb, key, status, body))
568    }
569}
570
571fn status_err(verb: &str, key: &str, status: StatusCode, body: Option<String>) -> Error {
572    let snippet = body
573        .as_deref()
574        .map(|s| s.chars().take(200).collect::<String>())
575        .unwrap_or_default();
576    let msg = format!("{verb} {key} → {status} {snippet}");
577    match status {
578        StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(msg),
579        StatusCode::NOT_FOUND => Error::NotFound(msg),
580        _ => Error::Backend(msg),
581    }
582}
583
584/// Parse a `ListObjectsV2` XML response for keys + next continuation token.
585///
586/// Deliberately tiny — full XML parsing is overkill for the two elements we
587/// care about. Looks for `<Key>...</Key>` and `<NextContinuationToken>...`
588/// inside the body. If R2 ever changes the element shape (it won't — it's
589/// S3-compat), the integration test catches it.
590fn parse_list_v2(body: &str) -> (Vec<String>, Option<String>) {
591    let keys = extract_all_tags(body, "Key");
592    let next = extract_first_tag(body, "NextContinuationToken");
593    let truncated = extract_first_tag(body, "IsTruncated")
594        .map(|v| v.trim().eq_ignore_ascii_case("true"))
595        .unwrap_or(false);
596    (keys, if truncated { next } else { None })
597}
598
599/// Parse `<Contents>` blocks for key + size + last-modified.
600///
601/// R2's `<Contents>` always has `<Key>` followed by `<LastModified>` and
602/// `<Size>` siblings. We walk `<Contents>...</Contents>` blocks and pull the
603/// three tags from each — order-insensitive within the block. Entries missing
604/// any of the three are skipped (defensive — R2 always emits all three).
605fn parse_list_v2_detailed(body: &str) -> (Vec<ObjectMeta>, Option<String>) {
606    let blocks = extract_all_tags(body, "Contents");
607    let entries = blocks
608        .into_iter()
609        .filter_map(|block| {
610            let key = extract_first_tag(&block, "Key")?;
611            let size = extract_first_tag(&block, "Size")?.trim().parse::<u64>().ok()?;
612            let last_modified = extract_first_tag(&block, "LastModified")?;
613            Some(ObjectMeta { key, size, last_modified })
614        })
615        .collect();
616    let next = extract_first_tag(body, "NextContinuationToken");
617    let truncated = extract_first_tag(body, "IsTruncated")
618        .map(|v| v.trim().eq_ignore_ascii_case("true"))
619        .unwrap_or(false);
620    (entries, if truncated { next } else { None })
621}
622
623fn extract_all_tags(body: &str, tag: &str) -> Vec<String> {
624    let open = format!("<{tag}>");
625    let close = format!("</{tag}>");
626    let mut out = Vec::new();
627    let mut search = body;
628    while let Some(start) = search.find(&open) {
629        let content_start = start + open.len();
630        if let Some(end) = search[content_start..].find(&close) {
631            out.push(search[content_start..content_start + end].to_string());
632            search = &search[content_start + end + close.len()..];
633        } else {
634            break;
635        }
636    }
637    out
638}
639
640fn extract_first_tag(body: &str, tag: &str) -> Option<String> {
641    extract_all_tags(body, tag).into_iter().next()
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn with_endpoint_redirects_every_url_and_leaves_r2_alone() {
650        let store = R2ObjectStore::new("acct", "yah-dev", "k", "s").unwrap();
651        assert_eq!(
652            store.object_url("yah/index.json"),
653            "https://acct.r2.cloudflarestorage.com/yah-dev/yah/index.json"
654        );
655
656        // Pond tier: same store, MinIO endpoint, path-style bucket preserved.
657        let pond = R2ObjectStore::new("pond", "yah-dev", "k", "s")
658            .unwrap()
659            .with_endpoint("http://127.0.0.1:9000");
660        assert_eq!(
661            pond.object_url("yah/index.json"),
662            "http://127.0.0.1:9000/yah-dev/yah/index.json"
663        );
664        assert_eq!(pond.bucket_url(), "http://127.0.0.1:9000/yah-dev");
665    }
666
667    #[test]
668    fn with_endpoint_normalizes_trailing_slash_and_ignores_empty() {
669        let s = R2ObjectStore::new("acct", "b", "k", "s")
670            .unwrap()
671            .with_endpoint("http://127.0.0.1:9000/");
672        assert_eq!(s.object_url("k1"), "http://127.0.0.1:9000/b/k1");
673        // An empty override is a config mistake, not an instruction to sign
674        // against the empty host — fall back to the derived R2 endpoint.
675        let s = R2ObjectStore::new("acct", "b", "k", "s")
676            .unwrap()
677            .with_endpoint("");
678        assert_eq!(
679            s.object_url("k1"),
680            "https://acct.r2.cloudflarestorage.com/b/k1"
681        );
682    }
683
684    /// Accept exactly one HTTP request on an ephemeral loopback port, answer
685    /// `200`, and hand the raw request head back. Enough of a server to prove
686    /// what went onto the wire, and no more — the point is the headers, and a
687    /// mock at the `reqwest` layer would only re-assert what the signer already
688    /// returned rather than what the client actually sent.
689    fn one_shot_http() -> (String, std::thread::JoinHandle<String>) {
690        use std::io::{BufRead, BufReader, Read, Write};
691
692        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
693        let url = format!("http://{}", listener.local_addr().unwrap());
694        let handle = std::thread::spawn(move || {
695            let (stream, _) = listener.accept().unwrap();
696            let mut reader = BufReader::new(stream);
697            let mut head = String::new();
698            loop {
699                let mut line = String::new();
700                if reader.read_line(&mut line).unwrap() == 0 {
701                    break;
702                }
703                let done = line == "\r\n";
704                head.push_str(&line);
705                if done {
706                    break;
707                }
708            }
709            // Drain the body, else the client sees the connection close
710            // mid-write and reports a broken pipe instead of our 200.
711            let len: usize = head
712                .lines()
713                .find_map(|l| {
714                    l.strip_prefix("content-length: ")
715                        .or_else(|| l.strip_prefix("Content-Length: "))
716                })
717                .and_then(|v| v.trim().parse().ok())
718                .unwrap_or(0);
719            let mut body = vec![0u8; len];
720            reader.read_exact(&mut body).unwrap();
721            reader
722                .into_inner()
723                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
724                .unwrap();
725            head
726        });
727        (url, handle)
728    }
729
730    /// R703-B8, the end of the chain: the signer builds a `Cache-Control` and
731    /// `reqwest` has to actually put it on the socket. Every other test here
732    /// stops at the `HeaderMap`, which is one `.headers()` call away from being
733    /// a test that passes while R2 stores an object with no directive.
734    #[test]
735    fn put_cached_sends_the_cache_control_header_on_the_wire() {
736        let (endpoint, server) = one_shot_http();
737        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
738            .unwrap()
739            .with_endpoint(endpoint);
740
741        store
742            .put_cached(
743                "yah-desktop/latest.json",
744                b"{\"version\":\"0.8.22\"}".to_vec(),
745                crate::CACHE_CONTROL_NO_CACHE,
746            )
747            .unwrap();
748
749        let head = server.join().unwrap().to_lowercase();
750        assert!(
751            head.starts_with("put /yah-dev/yah-desktop/latest.json "),
752            "{head}"
753        );
754        assert!(head.contains("cache-control: no-cache, max-age=0\r\n"), "{head}");
755        // Sent AND signed — an unsigned header R2 would reject the request over.
756        assert!(
757            head.contains("signedheaders=cache-control;content-length;content-type;host;"),
758            "{head}"
759        );
760    }
761
762    /// The other half: a plain `put` must still send no directive at all. If it
763    /// quietly gained a default, versioned release bytes would start carrying
764    /// whatever that default was.
765    #[test]
766    fn a_plain_put_sends_no_cache_control_header() {
767        let (endpoint, server) = one_shot_http();
768        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
769            .unwrap()
770            .with_endpoint(endpoint);
771
772        store.put("some/blob.bin", b"bytes".to_vec()).unwrap();
773
774        let head = server.join().unwrap().to_lowercase();
775        assert!(!head.contains("cache-control"), "{head}");
776    }
777
778    #[test]
779    fn parse_list_v2_extracts_keys() {
780        let body = r#"<?xml version="1.0" encoding="UTF-8"?>
781            <ListBucketResult>
782                <IsTruncated>false</IsTruncated>
783                <Contents><Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key></Contents>
784                <Contents><Key>yubaba/release-manifest.json</Key></Contents>
785            </ListBucketResult>"#;
786        let (keys, next) = parse_list_v2(body);
787        assert_eq!(
788            keys,
789            vec![
790                "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz".to_string(),
791                "yubaba/release-manifest.json".to_string(),
792            ]
793        );
794        assert!(next.is_none());
795    }
796
797    #[test]
798    fn parse_list_v2_returns_continuation_when_truncated() {
799        let body = r#"<ListBucketResult>
800                <IsTruncated>true</IsTruncated>
801                <NextContinuationToken>abc123</NextContinuationToken>
802                <Contents><Key>a</Key></Contents>
803            </ListBucketResult>"#;
804        let (keys, next) = parse_list_v2(body);
805        assert_eq!(keys, vec!["a".to_string()]);
806        assert_eq!(next.as_deref(), Some("abc123"));
807    }
808
809    #[test]
810    fn parse_list_v2_ignores_token_when_not_truncated() {
811        // Some S3-compat impls emit NextContinuationToken with IsTruncated=false.
812        // We treat IsTruncated as load-bearing.
813        let body = r#"<ListBucketResult>
814                <IsTruncated>false</IsTruncated>
815                <NextContinuationToken>stale</NextContinuationToken>
816                <Contents><Key>a</Key></Contents>
817            </ListBucketResult>"#;
818        let (_, next) = parse_list_v2(body);
819        assert!(next.is_none());
820    }
821
822    #[test]
823    fn parse_list_v2_detailed_extracts_size_and_mtime() {
824        let body = r#"<?xml version="1.0" encoding="UTF-8"?>
825            <ListBucketResult>
826                <IsTruncated>false</IsTruncated>
827                <Contents>
828                    <Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key>
829                    <LastModified>2026-06-08T20:14:32.000Z</LastModified>
830                    <ETag>"abc"</ETag>
831                    <Size>4823104</Size>
832                    <StorageClass>STANDARD</StorageClass>
833                </Contents>
834                <Contents>
835                    <Key>yubaba/release-manifest.json</Key>
836                    <LastModified>2026-06-08T20:14:35.000Z</LastModified>
837                    <Size>412</Size>
838                </Contents>
839            </ListBucketResult>"#;
840        let (entries, next) = parse_list_v2_detailed(body);
841        assert_eq!(entries.len(), 2);
842        assert_eq!(entries[0].key, "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz");
843        assert_eq!(entries[0].size, 4823104);
844        assert_eq!(entries[0].last_modified, "2026-06-08T20:14:32.000Z");
845        assert_eq!(entries[1].key, "yubaba/release-manifest.json");
846        assert_eq!(entries[1].size, 412);
847        assert!(next.is_none());
848    }
849
850    #[test]
851    fn r2_object_store_constructs_with_explicit_keys() {
852        let s = R2ObjectStore::new("acct", "yah-dev", "AK", "SK").unwrap();
853        assert_eq!(s.object_url("k"), "https://acct.r2.cloudflarestorage.com/yah-dev/k");
854        assert_eq!(s.bucket_url(), "https://acct.r2.cloudflarestorage.com/yah-dev");
855    }
856
857    #[test]
858    fn object_url_preserves_slashes_in_key() {
859        let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
860        assert_eq!(
861            s.object_url("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"),
862            "https://acct.r2.cloudflarestorage.com/b/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"
863        );
864    }
865
866    #[test]
867    fn content_type_inferred_from_extension() {
868        assert_eq!(
869            content_type_for_key("yah-marketing/cloud/index.html"),
870            "text/html; charset=utf-8"
871        );
872        assert_eq!(content_type_for_key("app.css"), "text/css; charset=utf-8");
873        assert_eq!(content_type_for_key("bundle.mjs"), "text/javascript; charset=utf-8");
874        assert_eq!(content_type_for_key("illustrations/horse.webp"), "image/webp");
875        assert_eq!(content_type_for_key("manifest.json"), "application/json");
876        // Extensionless keys (pointers) and dotted directory segments fall back.
877        assert_eq!(content_type_for_key("pointers/releases"), DEFAULT_CONTENT_TYPE);
878        assert_eq!(content_type_for_key("v1.2/binary"), DEFAULT_CONTENT_TYPE);
879    }
880}