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 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct ObjectMeta {
484 pub key: String,
486 pub size: u64,
488 pub last_modified: String,
490}
491
492impl R2ObjectStore {
493 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 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
580fn 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
595fn 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 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 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 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 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 #[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 assert!(
753 head.contains("signedheaders=cache-control;content-length;content-type;host;"),
754 "{head}"
755 );
756 }
757
758 #[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 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 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}