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 put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
284        self.put_inner(key, data, None)
285    }
286
287    fn put_cached(&self, key: &str, data: Vec<u8>, cache_control: &str) -> Result<(), Error> {
288        self.put_inner(key, data, Some(cache_control))
289    }
290
291    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
292        let url = self.object_url(key);
293        // GET has no body: reqwest drops the `content-length: 0` header on the
294        // wire, so signing it (as `sign_s3_empty_body` does) yields a signature
295        // the server can't reproduce → 403 SignatureDoesNotMatch. Sign with the
296        // content-length-free helper instead, exactly like ListObjectsV2. The
297        // empty query string is correct for a plain object GET.
298        let headers = sign_s3_get_with_query(
299            &url,
300            "",
301            R2_REGION,
302            &self.access_key,
303            &self.secret_key,
304        )
305        .map_err(|e| Error::Backend(format!("sign GET {key}: {e}")))?;
306
307        let resp = self
308            .client()
309            .get(&url)
310            .headers(headers)
311            .send()
312            .map_err(|e| io_err(&format!("GET {key}"), e))?;
313
314        match resp.status() {
315            StatusCode::OK => {
316                let bytes = resp
317                    .bytes()
318                    .map_err(|e| io_err(&format!("read GET {key}"), e))?;
319                Ok(Some(bytes.to_vec()))
320            }
321            StatusCode::NOT_FOUND => Ok(None),
322            s => Err(status_err("GET", key, s, resp.text().ok())),
323        }
324    }
325
326    fn head(&self, key: &str) -> Result<bool, Error> {
327        let url = self.object_url(key);
328        // HEAD is body-less like GET: sign without content-length (see `get`).
329        let headers = sign_s3_no_body(
330            "HEAD",
331            &url,
332            "",
333            R2_REGION,
334            &self.access_key,
335            &self.secret_key,
336        )
337        .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
338
339        let resp = self
340            .client()
341            .head(&url)
342            .headers(headers)
343            .send()
344            .map_err(|e| io_err(&format!("HEAD {key}"), e))?;
345
346        match resp.status() {
347            StatusCode::OK => Ok(true),
348            StatusCode::NOT_FOUND => Ok(false),
349            s => Err(status_err("HEAD", key, s, None)),
350        }
351    }
352
353    fn delete(&self, key: &str) -> Result<(), Error> {
354        let url = self.object_url(key);
355        let headers = sign_s3_empty_body(
356            "DELETE",
357            &url,
358            R2_REGION,
359            &self.access_key,
360            &self.secret_key,
361        )
362        .map_err(|e| Error::Backend(format!("sign DELETE {key}: {e}")))?;
363
364        let resp = self
365            .client()
366            .delete(&url)
367            .headers(headers)
368            .send()
369            .map_err(|e| io_err(&format!("DELETE {key}"), e))?;
370
371        match resp.status() {
372            // S3 DELETE on a missing key returns 204 too — both are success
373            // semantics for an idempotent delete.
374            StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
375            s => Err(status_err("DELETE", key, s, resp.text().ok())),
376        }
377    }
378
379    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
380        Ok(self
381            .list_prefix_detailed(prefix)?
382            .into_iter()
383            .map(|m| m.key)
384            .collect())
385    }
386
387    fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
388        let url = self.object_url(key);
389        let body_sha256 = {
390            let mut h = Sha256::new();
391            h.update(&data);
392            hex::encode(h.finalize())
393        };
394        // Sign the same fixed header set as an unconditional PUT. The conditional
395        // header (If-Match / If-None-Match) is added *unsigned* afterwards: SigV4
396        // only covers the headers in `SignedHeaders`, and S3/R2 honor extra
397        // unsigned headers — so the precondition is enforced server-side without
398        // touching the signer.
399        let mut headers = sign_s3_put_object(
400            &url,
401            &body_sha256,
402            content_type_for_key(key),
403            data.len(),
404            R2_REGION,
405            &self.access_key,
406            &self.secret_key,
407            None,
408        )
409        .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
410
411        match &cond {
412            Precondition::IfAbsent => {
413                headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*"));
414            }
415            Precondition::IfMatch(etag) => {
416                let v = HeaderValue::from_str(etag)
417                    .map_err(|e| Error::Backend(format!("invalid If-Match etag {etag:?}: {e}")))?;
418                headers.insert(IF_MATCH, v);
419            }
420        }
421
422        let resp = self
423            .client()
424            .put(&url)
425            .headers(headers)
426            .body(data)
427            .send()
428            .map_err(|e| io_err(&format!("PUT(if) {key}"), e))?;
429
430        let status = resp.status();
431        if status == StatusCode::PRECONDITION_FAILED {
432            return Err(Error::PreconditionFailed(format!(
433                "put_if {key}: precondition not met ({cond:?})"
434            )));
435        }
436        if !status.is_success() {
437            return Err(status_err("PUT(if)", key, status, resp.text().ok()));
438        }
439        // Prefer the ETag echoed in the PUT response; fall back to a HEAD if a
440        // backend ever omits it (R2 always returns it).
441        match resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) {
442            Some(e) => Ok(e.to_string()),
443            None => self
444                .etag(key)?
445                .ok_or_else(|| Error::Backend(format!("PUT(if) {key} returned no ETag"))),
446        }
447    }
448
449    fn etag(&self, key: &str) -> Result<Option<String>, Error> {
450        let url = self.object_url(key);
451        // HEAD is body-less: sign without content-length (see `head`).
452        let headers = sign_s3_no_body(
453            "HEAD",
454            &url,
455            "",
456            R2_REGION,
457            &self.access_key,
458            &self.secret_key,
459        )
460        .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
461
462        let resp = self
463            .client()
464            .head(&url)
465            .headers(headers)
466            .send()
467            .map_err(|e| io_err(&format!("HEAD(etag) {key}"), e))?;
468
469        match resp.status() {
470            StatusCode::OK => Ok(resp
471                .headers()
472                .get(ETAG)
473                .and_then(|v| v.to_str().ok())
474                .map(|s| s.to_string())),
475            StatusCode::NOT_FOUND => Ok(None),
476            s => Err(status_err("HEAD(etag)", key, s, None)),
477        }
478    }
479}
480
481/// One `<Contents>` entry from an R2 `ListObjectsV2` response.
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct ObjectMeta {
484    /// Object key (full path including any prefix).
485    pub key: String,
486    /// Object size in bytes.
487    pub size: u64,
488    /// Last-modified timestamp in ISO-8601 / RFC-3339 (R2's `<LastModified>` value).
489    pub last_modified: String,
490}
491
492impl R2ObjectStore {
493    /// List objects under `prefix` returning key + size + last-modified.
494    ///
495    /// Same paginated request as [`ObjectStore::list_prefix`] but parses the
496    /// `<Size>` and `<LastModified>` siblings of each `<Key>` element. Used by
497    /// the data-tab bucket viewer to render a directory-style listing.
498    pub fn list_prefix_detailed(&self, prefix: &str) -> Result<Vec<ObjectMeta>, Error> {
499        let mut entries = Vec::new();
500        let mut continuation_token: Option<String> = None;
501        let bucket_url = self.bucket_url();
502        let encoded_prefix = utf8_percent_encode(prefix, QUERY_VALUE).to_string();
503
504        loop {
505            // Canonical query MUST be sorted by parameter name (SigV4).
506            // Parameters: continuation-token (optional), list-type, prefix.
507            let mut params: Vec<(String, String)> =
508                vec![("list-type".to_string(), "2".to_string())];
509            if let Some(token) = &continuation_token {
510                let encoded = utf8_percent_encode(token, QUERY_VALUE).to_string();
511                params.push(("continuation-token".to_string(), encoded));
512            }
513            params.push(("prefix".to_string(), encoded_prefix.clone()));
514            params.sort_by(|a, b| a.0.cmp(&b.0));
515            let canonical_query = params
516                .iter()
517                .map(|(k, v)| format!("{k}={v}"))
518                .collect::<Vec<_>>()
519                .join("&");
520
521            let url_with_query = format!("{bucket_url}?{canonical_query}");
522
523            let headers = sign_s3_get_with_query(
524                &bucket_url,
525                &canonical_query,
526                R2_REGION,
527                &self.access_key,
528                &self.secret_key,
529            )
530            .map_err(|e| Error::Backend(format!("sign LIST {prefix}: {e}")))?;
531
532            let resp = self
533                .client()
534                .get(&url_with_query)
535                .headers(headers)
536                .send()
537                .map_err(|e| io_err(&format!("LIST {prefix}"), e))?;
538
539            if !resp.status().is_success() {
540                return Err(status_err("LIST", prefix, resp.status(), resp.text().ok()));
541            }
542            let body = resp
543                .text()
544                .map_err(|e| io_err(&format!("LIST {prefix} body"), e))?;
545            let (page_entries, next_token) = parse_list_v2_detailed(&body);
546            entries.extend(page_entries);
547            if let Some(t) = next_token {
548                continuation_token = Some(t);
549            } else {
550                break;
551            }
552        }
553        Ok(entries)
554    }
555}
556
557fn check_status(resp: reqwest::blocking::Response, verb: &str, key: &str) -> Result<(), Error> {
558    if resp.status().is_success() {
559        Ok(())
560    } else {
561        let status = resp.status();
562        let body = resp.text().ok();
563        Err(status_err(verb, key, status, body))
564    }
565}
566
567fn status_err(verb: &str, key: &str, status: StatusCode, body: Option<String>) -> Error {
568    let snippet = body
569        .as_deref()
570        .map(|s| s.chars().take(200).collect::<String>())
571        .unwrap_or_default();
572    let msg = format!("{verb} {key} → {status} {snippet}");
573    match status {
574        StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(msg),
575        StatusCode::NOT_FOUND => Error::NotFound(msg),
576        _ => Error::Backend(msg),
577    }
578}
579
580/// Parse a `ListObjectsV2` XML response for keys + next continuation token.
581///
582/// Deliberately tiny — full XML parsing is overkill for the two elements we
583/// care about. Looks for `<Key>...</Key>` and `<NextContinuationToken>...`
584/// inside the body. If R2 ever changes the element shape (it won't — it's
585/// S3-compat), the integration test catches it.
586fn parse_list_v2(body: &str) -> (Vec<String>, Option<String>) {
587    let keys = extract_all_tags(body, "Key");
588    let next = extract_first_tag(body, "NextContinuationToken");
589    let truncated = extract_first_tag(body, "IsTruncated")
590        .map(|v| v.trim().eq_ignore_ascii_case("true"))
591        .unwrap_or(false);
592    (keys, if truncated { next } else { None })
593}
594
595/// Parse `<Contents>` blocks for key + size + last-modified.
596///
597/// R2's `<Contents>` always has `<Key>` followed by `<LastModified>` and
598/// `<Size>` siblings. We walk `<Contents>...</Contents>` blocks and pull the
599/// three tags from each — order-insensitive within the block. Entries missing
600/// any of the three are skipped (defensive — R2 always emits all three).
601fn parse_list_v2_detailed(body: &str) -> (Vec<ObjectMeta>, Option<String>) {
602    let blocks = extract_all_tags(body, "Contents");
603    let entries = blocks
604        .into_iter()
605        .filter_map(|block| {
606            let key = extract_first_tag(&block, "Key")?;
607            let size = extract_first_tag(&block, "Size")?.trim().parse::<u64>().ok()?;
608            let last_modified = extract_first_tag(&block, "LastModified")?;
609            Some(ObjectMeta { key, size, last_modified })
610        })
611        .collect();
612    let next = extract_first_tag(body, "NextContinuationToken");
613    let truncated = extract_first_tag(body, "IsTruncated")
614        .map(|v| v.trim().eq_ignore_ascii_case("true"))
615        .unwrap_or(false);
616    (entries, if truncated { next } else { None })
617}
618
619fn extract_all_tags(body: &str, tag: &str) -> Vec<String> {
620    let open = format!("<{tag}>");
621    let close = format!("</{tag}>");
622    let mut out = Vec::new();
623    let mut search = body;
624    while let Some(start) = search.find(&open) {
625        let content_start = start + open.len();
626        if let Some(end) = search[content_start..].find(&close) {
627            out.push(search[content_start..content_start + end].to_string());
628            search = &search[content_start + end + close.len()..];
629        } else {
630            break;
631        }
632    }
633    out
634}
635
636fn extract_first_tag(body: &str, tag: &str) -> Option<String> {
637    extract_all_tags(body, tag).into_iter().next()
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn with_endpoint_redirects_every_url_and_leaves_r2_alone() {
646        let store = R2ObjectStore::new("acct", "yah-dev", "k", "s").unwrap();
647        assert_eq!(
648            store.object_url("yah/index.json"),
649            "https://acct.r2.cloudflarestorage.com/yah-dev/yah/index.json"
650        );
651
652        // Pond tier: same store, MinIO endpoint, path-style bucket preserved.
653        let pond = R2ObjectStore::new("pond", "yah-dev", "k", "s")
654            .unwrap()
655            .with_endpoint("http://127.0.0.1:9000");
656        assert_eq!(
657            pond.object_url("yah/index.json"),
658            "http://127.0.0.1:9000/yah-dev/yah/index.json"
659        );
660        assert_eq!(pond.bucket_url(), "http://127.0.0.1:9000/yah-dev");
661    }
662
663    #[test]
664    fn with_endpoint_normalizes_trailing_slash_and_ignores_empty() {
665        let s = R2ObjectStore::new("acct", "b", "k", "s")
666            .unwrap()
667            .with_endpoint("http://127.0.0.1:9000/");
668        assert_eq!(s.object_url("k1"), "http://127.0.0.1:9000/b/k1");
669        // An empty override is a config mistake, not an instruction to sign
670        // against the empty host — fall back to the derived R2 endpoint.
671        let s = R2ObjectStore::new("acct", "b", "k", "s")
672            .unwrap()
673            .with_endpoint("");
674        assert_eq!(
675            s.object_url("k1"),
676            "https://acct.r2.cloudflarestorage.com/b/k1"
677        );
678    }
679
680    /// Accept exactly one HTTP request on an ephemeral loopback port, answer
681    /// `200`, and hand the raw request head back. Enough of a server to prove
682    /// what went onto the wire, and no more — the point is the headers, and a
683    /// mock at the `reqwest` layer would only re-assert what the signer already
684    /// returned rather than what the client actually sent.
685    fn one_shot_http() -> (String, std::thread::JoinHandle<String>) {
686        use std::io::{BufRead, BufReader, Read, Write};
687
688        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
689        let url = format!("http://{}", listener.local_addr().unwrap());
690        let handle = std::thread::spawn(move || {
691            let (stream, _) = listener.accept().unwrap();
692            let mut reader = BufReader::new(stream);
693            let mut head = String::new();
694            loop {
695                let mut line = String::new();
696                if reader.read_line(&mut line).unwrap() == 0 {
697                    break;
698                }
699                let done = line == "\r\n";
700                head.push_str(&line);
701                if done {
702                    break;
703                }
704            }
705            // Drain the body, else the client sees the connection close
706            // mid-write and reports a broken pipe instead of our 200.
707            let len: usize = head
708                .lines()
709                .find_map(|l| {
710                    l.strip_prefix("content-length: ")
711                        .or_else(|| l.strip_prefix("Content-Length: "))
712                })
713                .and_then(|v| v.trim().parse().ok())
714                .unwrap_or(0);
715            let mut body = vec![0u8; len];
716            reader.read_exact(&mut body).unwrap();
717            reader
718                .into_inner()
719                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
720                .unwrap();
721            head
722        });
723        (url, handle)
724    }
725
726    /// R703-B8, the end of the chain: the signer builds a `Cache-Control` and
727    /// `reqwest` has to actually put it on the socket. Every other test here
728    /// stops at the `HeaderMap`, which is one `.headers()` call away from being
729    /// a test that passes while R2 stores an object with no directive.
730    #[test]
731    fn put_cached_sends_the_cache_control_header_on_the_wire() {
732        let (endpoint, server) = one_shot_http();
733        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
734            .unwrap()
735            .with_endpoint(endpoint);
736
737        store
738            .put_cached(
739                "yah-desktop/latest.json",
740                b"{\"version\":\"0.8.22\"}".to_vec(),
741                crate::CACHE_CONTROL_NO_CACHE,
742            )
743            .unwrap();
744
745        let head = server.join().unwrap().to_lowercase();
746        assert!(
747            head.starts_with("put /yah-dev/yah-desktop/latest.json "),
748            "{head}"
749        );
750        assert!(head.contains("cache-control: no-cache, max-age=0\r\n"), "{head}");
751        // Sent AND signed — an unsigned header R2 would reject the request over.
752        assert!(
753            head.contains("signedheaders=cache-control;content-length;content-type;host;"),
754            "{head}"
755        );
756    }
757
758    /// The other half: a plain `put` must still send no directive at all. If it
759    /// quietly gained a default, versioned release bytes would start carrying
760    /// whatever that default was.
761    #[test]
762    fn a_plain_put_sends_no_cache_control_header() {
763        let (endpoint, server) = one_shot_http();
764        let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
765            .unwrap()
766            .with_endpoint(endpoint);
767
768        store.put("some/blob.bin", b"bytes".to_vec()).unwrap();
769
770        let head = server.join().unwrap().to_lowercase();
771        assert!(!head.contains("cache-control"), "{head}");
772    }
773
774    #[test]
775    fn parse_list_v2_extracts_keys() {
776        let body = r#"<?xml version="1.0" encoding="UTF-8"?>
777            <ListBucketResult>
778                <IsTruncated>false</IsTruncated>
779                <Contents><Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key></Contents>
780                <Contents><Key>yubaba/release-manifest.json</Key></Contents>
781            </ListBucketResult>"#;
782        let (keys, next) = parse_list_v2(body);
783        assert_eq!(
784            keys,
785            vec![
786                "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz".to_string(),
787                "yubaba/release-manifest.json".to_string(),
788            ]
789        );
790        assert!(next.is_none());
791    }
792
793    #[test]
794    fn parse_list_v2_returns_continuation_when_truncated() {
795        let body = r#"<ListBucketResult>
796                <IsTruncated>true</IsTruncated>
797                <NextContinuationToken>abc123</NextContinuationToken>
798                <Contents><Key>a</Key></Contents>
799            </ListBucketResult>"#;
800        let (keys, next) = parse_list_v2(body);
801        assert_eq!(keys, vec!["a".to_string()]);
802        assert_eq!(next.as_deref(), Some("abc123"));
803    }
804
805    #[test]
806    fn parse_list_v2_ignores_token_when_not_truncated() {
807        // Some S3-compat impls emit NextContinuationToken with IsTruncated=false.
808        // We treat IsTruncated as load-bearing.
809        let body = r#"<ListBucketResult>
810                <IsTruncated>false</IsTruncated>
811                <NextContinuationToken>stale</NextContinuationToken>
812                <Contents><Key>a</Key></Contents>
813            </ListBucketResult>"#;
814        let (_, next) = parse_list_v2(body);
815        assert!(next.is_none());
816    }
817
818    #[test]
819    fn parse_list_v2_detailed_extracts_size_and_mtime() {
820        let body = r#"<?xml version="1.0" encoding="UTF-8"?>
821            <ListBucketResult>
822                <IsTruncated>false</IsTruncated>
823                <Contents>
824                    <Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key>
825                    <LastModified>2026-06-08T20:14:32.000Z</LastModified>
826                    <ETag>"abc"</ETag>
827                    <Size>4823104</Size>
828                    <StorageClass>STANDARD</StorageClass>
829                </Contents>
830                <Contents>
831                    <Key>yubaba/release-manifest.json</Key>
832                    <LastModified>2026-06-08T20:14:35.000Z</LastModified>
833                    <Size>412</Size>
834                </Contents>
835            </ListBucketResult>"#;
836        let (entries, next) = parse_list_v2_detailed(body);
837        assert_eq!(entries.len(), 2);
838        assert_eq!(entries[0].key, "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz");
839        assert_eq!(entries[0].size, 4823104);
840        assert_eq!(entries[0].last_modified, "2026-06-08T20:14:32.000Z");
841        assert_eq!(entries[1].key, "yubaba/release-manifest.json");
842        assert_eq!(entries[1].size, 412);
843        assert!(next.is_none());
844    }
845
846    #[test]
847    fn r2_object_store_constructs_with_explicit_keys() {
848        let s = R2ObjectStore::new("acct", "yah-dev", "AK", "SK").unwrap();
849        assert_eq!(s.object_url("k"), "https://acct.r2.cloudflarestorage.com/yah-dev/k");
850        assert_eq!(s.bucket_url(), "https://acct.r2.cloudflarestorage.com/yah-dev");
851    }
852
853    #[test]
854    fn object_url_preserves_slashes_in_key() {
855        let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
856        assert_eq!(
857            s.object_url("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"),
858            "https://acct.r2.cloudflarestorage.com/b/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"
859        );
860    }
861
862    #[test]
863    fn content_type_inferred_from_extension() {
864        assert_eq!(
865            content_type_for_key("yah-marketing/cloud/index.html"),
866            "text/html; charset=utf-8"
867        );
868        assert_eq!(content_type_for_key("app.css"), "text/css; charset=utf-8");
869        assert_eq!(content_type_for_key("bundle.mjs"), "text/javascript; charset=utf-8");
870        assert_eq!(content_type_for_key("illustrations/horse.webp"), "image/webp");
871        assert_eq!(content_type_for_key("manifest.json"), "application/json");
872        // Extensionless keys (pointers) and dotted directory segments fall back.
873        assert_eq!(content_type_for_key("pointers/releases"), DEFAULT_CONTENT_TYPE);
874        assert_eq!(content_type_for_key("v1.2/binary"), DEFAULT_CONTENT_TYPE);
875    }
876}