1use 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
43const R2_REGION: &str = "auto";
45
46pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
48pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
50pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
52pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
54
55const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
59 .remove(b'-')
60 .remove(b'_')
61 .remove(b'.')
62 .remove(b'~');
63
64const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
66
67fn content_type_for_key(key: &str) -> &'static str {
76 let ext = match key.rsplit_once('.') {
77 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
106pub struct R2ObjectStore {
112 account_id: String,
113 bucket: String,
114 access_key: String,
115 secret_key: String,
116 endpoint: Option<String>,
119 client: Option<Client>,
120}
121
122impl Drop for R2ObjectStore {
123 fn drop(&mut self) {
124 let Some(client) = self.client.take() else { return };
133 std::thread::spawn(move || drop(client));
134 }
135}
136
137impl R2ObjectStore {
138 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 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 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 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 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
277fn 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct ObjectMeta {
488 pub key: String,
490 pub size: u64,
492 pub last_modified: String,
494}
495
496impl R2ObjectStore {
497 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 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
584fn 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
599fn 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 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 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 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 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 #[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 assert!(
757 head.contains("signedheaders=cache-control;content-length;content-type;host;"),
758 "{head}"
759 );
760 }
761
762 #[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 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 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}