1use std::time::Duration;
31
32use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
33use reqwest::blocking::Client;
34use reqwest::header::{HeaderValue, ETAG, IF_MATCH, IF_NONE_MATCH};
35use reqwest::StatusCode;
36use sha2::{Digest, Sha256};
37
38use local_driver::s3_sign::{
39 sign_s3_get_with_query, sign_s3_no_body, sign_s3_put_object, sign_s3_put_object_with,
40 uri_encode_key, S3PutOptions,
41};
42
43use crate::{Error, ObjectStore, Precondition};
44
45const R2_REGION: &str = "auto";
47
48pub const R2_ACCESS_KEY_SLOT: &str = "cloudflare-r2-access-key-id";
50pub const R2_SECRET_KEY_SLOT: &str = "cloudflare-r2-secret-key";
52pub const R2_ACCESS_KEY_ENV: &str = "CF_R2_ACCESS_KEY_ID";
54pub const R2_SECRET_KEY_ENV: &str = "CF_R2_SECRET_KEY";
56
57const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
61 .remove(b'-')
62 .remove(b'_')
63 .remove(b'.')
64 .remove(b'~');
65
66const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
68
69fn content_type_for_key(key: &str) -> &'static str {
78 let ext = match key.rsplit_once('.') {
79 Some((_, e)) if !e.contains('/') => e,
81 _ => "",
82 };
83 match ext.to_ascii_lowercase().as_str() {
84 "html" | "htm" => "text/html; charset=utf-8",
85 "css" => "text/css; charset=utf-8",
86 "js" | "mjs" => "text/javascript; charset=utf-8",
87 "json" | "map" => "application/json",
88 "webmanifest" => "application/manifest+json",
89 "xml" => "application/xml",
90 "txt" => "text/plain; charset=utf-8",
91 "svg" => "image/svg+xml",
92 "webp" => "image/webp",
93 "png" => "image/png",
94 "jpg" | "jpeg" => "image/jpeg",
95 "gif" => "image/gif",
96 "avif" => "image/avif",
97 "ico" => "image/x-icon",
98 "woff2" => "font/woff2",
99 "woff" => "font/woff",
100 "ttf" => "font/ttf",
101 "otf" => "font/otf",
102 "wasm" => "application/wasm",
103 "pdf" => "application/pdf",
104 _ => DEFAULT_CONTENT_TYPE,
105 }
106}
107
108pub struct R2ObjectStore {
114 account_id: String,
115 bucket: String,
116 access_key: String,
117 secret_key: String,
118 endpoint: Option<String>,
121 client: Option<Client>,
122}
123
124impl Drop for R2ObjectStore {
125 fn drop(&mut self) {
126 let Some(client) = self.client.take() else { return };
135 std::thread::spawn(move || drop(client));
136 }
137}
138
139impl R2ObjectStore {
140 pub fn new(
145 account_id: impl Into<String>,
146 bucket: impl Into<String>,
147 access_key: impl Into<String>,
148 secret_key: impl Into<String>,
149 ) -> Result<Self, Error> {
150 let client = Client::builder()
151 .timeout(Duration::from_secs(300))
152 .build()
153 .map_err(|e| Error::Backend(format!("reqwest client: {e}")))?;
154 Ok(Self {
155 account_id: account_id.into(),
156 bucket: bucket.into(),
157 access_key: access_key.into(),
158 secret_key: secret_key.into(),
159 endpoint: None,
160 client: Some(client),
161 })
162 }
163
164 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
181 let endpoint = endpoint.into();
182 let trimmed = endpoint.trim_end_matches('/');
183 self.endpoint = (!trimmed.is_empty()).then(|| trimmed.to_string());
184 self
185 }
186
187 fn client(&self) -> &Client {
188 self.client
189 .as_ref()
190 .expect("client is Some until Drop takes it")
191 }
192
193 pub fn from_vault(
199 account_id: impl Into<String>,
200 bucket: impl Into<String>,
201 ) -> Result<Self, Error> {
202 let access_key = fob::get_or_env(R2_ACCESS_KEY_SLOT, R2_ACCESS_KEY_ENV)
203 .map_err(|e| Error::Auth(format!("vault read {R2_ACCESS_KEY_SLOT}: {e}")))?
204 .ok_or_else(|| {
205 Error::Auth(format!(
206 "missing R2 credential: set vault slot {R2_ACCESS_KEY_SLOT} or env {R2_ACCESS_KEY_ENV}"
207 ))
208 })?;
209 let secret_key = fob::get_or_env(R2_SECRET_KEY_SLOT, R2_SECRET_KEY_ENV)
210 .map_err(|e| Error::Auth(format!("vault read {R2_SECRET_KEY_SLOT}: {e}")))?
211 .ok_or_else(|| {
212 Error::Auth(format!(
213 "missing R2 credential: set vault slot {R2_SECRET_KEY_SLOT} or env {R2_SECRET_KEY_ENV}"
214 ))
215 })?;
216 Self::new(account_id, bucket, access_key, secret_key)
217 }
218
219 fn endpoint(&self) -> String {
220 match &self.endpoint {
221 Some(e) => e.clone(),
222 None => format!("https://{}.r2.cloudflarestorage.com", self.account_id),
223 }
224 }
225
226 fn object_url(&self, key: &str) -> String {
233 format!("{}/{}/{}", self.endpoint(), self.bucket, uri_encode_key(key))
234 }
235
236 fn bucket_url(&self) -> String {
237 format!("{}/{}", self.endpoint(), self.bucket)
238 }
239
240 fn put_inner(
246 &self,
247 key: &str,
248 data: Vec<u8>,
249 cache_control: Option<&str>,
250 ) -> Result<(), Error> {
251 let url = self.object_url(key);
252 let body_sha256 = {
253 let mut h = Sha256::new();
254 h.update(&data);
255 hex::encode(h.finalize())
256 };
257 let headers = sign_s3_put_object_with(
258 &url,
259 &body_sha256,
260 data.len(),
261 R2_REGION,
262 &self.access_key,
263 &self.secret_key,
264 &S3PutOptions {
265 content_type: content_type_for_key(key),
266 blake3_meta: None,
269 cache_control,
270 },
271 )
272 .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
273
274 let resp = self
275 .client()
276 .put(&url)
277 .headers(headers)
278 .body(data)
279 .send()
280 .map_err(|e| io_err(&format!("PUT {key}"), e))?;
281 check_status(resp, "PUT", key)
282 }
283}
284
285fn io_err(ctx: &str, e: impl std::fmt::Display) -> Error {
287 Error::Io(format!("{ctx}: {e}"))
288}
289
290impl ObjectStore for R2ObjectStore {
291 fn locate(&self, key: &str) -> String {
292 self.object_url(key)
293 }
294
295 fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
296 self.put_inner(key, data, None)
297 }
298
299 fn put_cached(&self, key: &str, data: Vec<u8>, cache_control: &str) -> Result<(), Error> {
300 self.put_inner(key, data, Some(cache_control))
301 }
302
303 fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
304 let url = self.object_url(key);
305 let headers = sign_s3_get_with_query(
311 &url,
312 "",
313 R2_REGION,
314 &self.access_key,
315 &self.secret_key,
316 )
317 .map_err(|e| Error::Backend(format!("sign GET {key}: {e}")))?;
318
319 let resp = self
320 .client()
321 .get(&url)
322 .headers(headers)
323 .send()
324 .map_err(|e| io_err(&format!("GET {key}"), e))?;
325
326 match resp.status() {
327 StatusCode::OK => {
328 let bytes = resp
329 .bytes()
330 .map_err(|e| io_err(&format!("read GET {key}"), e))?;
331 Ok(Some(bytes.to_vec()))
332 }
333 StatusCode::NOT_FOUND => Ok(None),
334 s => Err(status_err("GET", key, s, resp.text().ok())),
335 }
336 }
337
338 fn head(&self, key: &str) -> Result<bool, Error> {
339 let url = self.object_url(key);
340 let headers = sign_s3_no_body(
342 "HEAD",
343 &url,
344 "",
345 R2_REGION,
346 &self.access_key,
347 &self.secret_key,
348 )
349 .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
350
351 let resp = self
352 .client()
353 .head(&url)
354 .headers(headers)
355 .send()
356 .map_err(|e| io_err(&format!("HEAD {key}"), e))?;
357
358 match resp.status() {
359 StatusCode::OK => Ok(true),
360 StatusCode::NOT_FOUND => Ok(false),
361 s => Err(status_err("HEAD", key, s, None)),
362 }
363 }
364
365 fn delete(&self, key: &str) -> Result<(), Error> {
366 let url = self.object_url(key);
367 let headers = sign_s3_no_body(
375 "DELETE",
376 &url,
377 "",
378 R2_REGION,
379 &self.access_key,
380 &self.secret_key,
381 )
382 .map_err(|e| Error::Backend(format!("sign DELETE {key}: {e}")))?;
383
384 let resp = self
385 .client()
386 .delete(&url)
387 .headers(headers)
388 .send()
389 .map_err(|e| io_err(&format!("DELETE {key}"), e))?;
390
391 match resp.status() {
392 StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(()),
395 s => Err(status_err("DELETE", key, s, resp.text().ok())),
396 }
397 }
398
399 fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
400 Ok(self
401 .list_prefix_detailed(prefix)?
402 .into_iter()
403 .map(|m| m.key)
404 .collect())
405 }
406
407 fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
408 let url = self.object_url(key);
409 let body_sha256 = {
410 let mut h = Sha256::new();
411 h.update(&data);
412 hex::encode(h.finalize())
413 };
414 let mut headers = sign_s3_put_object(
420 &url,
421 &body_sha256,
422 content_type_for_key(key),
423 data.len(),
424 R2_REGION,
425 &self.access_key,
426 &self.secret_key,
427 None,
428 )
429 .map_err(|e| Error::Backend(format!("sign PUT {key}: {e}")))?;
430
431 match &cond {
432 Precondition::IfAbsent => {
433 headers.insert(IF_NONE_MATCH, HeaderValue::from_static("*"));
434 }
435 Precondition::IfMatch(etag) => {
436 let v = HeaderValue::from_str(etag)
437 .map_err(|e| Error::Backend(format!("invalid If-Match etag {etag:?}: {e}")))?;
438 headers.insert(IF_MATCH, v);
439 }
440 }
441
442 let resp = self
443 .client()
444 .put(&url)
445 .headers(headers)
446 .body(data)
447 .send()
448 .map_err(|e| io_err(&format!("PUT(if) {key}"), e))?;
449
450 let status = resp.status();
451 if status == StatusCode::PRECONDITION_FAILED {
452 return Err(Error::PreconditionFailed(format!(
453 "put_if {key}: precondition not met ({cond:?})"
454 )));
455 }
456 if !status.is_success() {
457 return Err(status_err("PUT(if)", key, status, resp.text().ok()));
458 }
459 match resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) {
462 Some(e) => Ok(e.to_string()),
463 None => self
464 .etag(key)?
465 .ok_or_else(|| Error::Backend(format!("PUT(if) {key} returned no ETag"))),
466 }
467 }
468
469 fn etag(&self, key: &str) -> Result<Option<String>, Error> {
470 let url = self.object_url(key);
471 let headers = sign_s3_no_body(
473 "HEAD",
474 &url,
475 "",
476 R2_REGION,
477 &self.access_key,
478 &self.secret_key,
479 )
480 .map_err(|e| Error::Backend(format!("sign HEAD {key}: {e}")))?;
481
482 let resp = self
483 .client()
484 .head(&url)
485 .headers(headers)
486 .send()
487 .map_err(|e| io_err(&format!("HEAD(etag) {key}"), e))?;
488
489 match resp.status() {
490 StatusCode::OK => Ok(resp
491 .headers()
492 .get(ETAG)
493 .and_then(|v| v.to_str().ok())
494 .map(|s| s.to_string())),
495 StatusCode::NOT_FOUND => Ok(None),
496 s => Err(status_err("HEAD(etag)", key, s, None)),
497 }
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct ObjectMeta {
504 pub key: String,
506 pub size: u64,
508 pub last_modified: String,
510}
511
512impl R2ObjectStore {
513 pub fn list_prefix_detailed(&self, prefix: &str) -> Result<Vec<ObjectMeta>, Error> {
519 let mut entries = Vec::new();
520 let mut continuation_token: Option<String> = None;
521 let bucket_url = self.bucket_url();
522 let encoded_prefix = utf8_percent_encode(prefix, QUERY_VALUE).to_string();
523
524 loop {
525 let mut params: Vec<(String, String)> =
528 vec![("list-type".to_string(), "2".to_string())];
529 if let Some(token) = &continuation_token {
530 let encoded = utf8_percent_encode(token, QUERY_VALUE).to_string();
531 params.push(("continuation-token".to_string(), encoded));
532 }
533 params.push(("prefix".to_string(), encoded_prefix.clone()));
534 params.sort_by(|a, b| a.0.cmp(&b.0));
535 let canonical_query = params
536 .iter()
537 .map(|(k, v)| format!("{k}={v}"))
538 .collect::<Vec<_>>()
539 .join("&");
540
541 let url_with_query = format!("{bucket_url}?{canonical_query}");
542
543 let headers = sign_s3_get_with_query(
544 &bucket_url,
545 &canonical_query,
546 R2_REGION,
547 &self.access_key,
548 &self.secret_key,
549 )
550 .map_err(|e| Error::Backend(format!("sign LIST {prefix}: {e}")))?;
551
552 let resp = self
553 .client()
554 .get(&url_with_query)
555 .headers(headers)
556 .send()
557 .map_err(|e| io_err(&format!("LIST {prefix}"), e))?;
558
559 if !resp.status().is_success() {
560 return Err(status_err("LIST", prefix, resp.status(), resp.text().ok()));
561 }
562 let body = resp
563 .text()
564 .map_err(|e| io_err(&format!("LIST {prefix} body"), e))?;
565 let (page_entries, next_token) = parse_list_v2_detailed(&body);
566 entries.extend(page_entries);
567 if let Some(t) = next_token {
568 continuation_token = Some(t);
569 } else {
570 break;
571 }
572 }
573 Ok(entries)
574 }
575}
576
577fn check_status(resp: reqwest::blocking::Response, verb: &str, key: &str) -> Result<(), Error> {
578 if resp.status().is_success() {
579 Ok(())
580 } else {
581 let status = resp.status();
582 let body = resp.text().ok();
583 Err(status_err(verb, key, status, body))
584 }
585}
586
587fn status_err(verb: &str, key: &str, status: StatusCode, body: Option<String>) -> Error {
588 let snippet = body
589 .as_deref()
590 .map(|s| s.chars().take(200).collect::<String>())
591 .unwrap_or_default();
592 let msg = format!("{verb} {key} → {status} {snippet}");
593 match status {
594 StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(msg),
595 StatusCode::NOT_FOUND => Error::NotFound(msg),
596 _ => Error::Backend(msg),
597 }
598}
599
600fn parse_list_v2(body: &str) -> (Vec<String>, Option<String>) {
607 let keys = extract_all_tags(body, "Key")
608 .iter()
609 .map(|k| decode_xml_entities(k))
610 .collect();
611 let next = extract_first_text(body, "NextContinuationToken");
612 let truncated = extract_first_tag(body, "IsTruncated")
613 .map(|v| v.trim().eq_ignore_ascii_case("true"))
614 .unwrap_or(false);
615 (keys, if truncated { next } else { None })
616}
617
618fn parse_list_v2_detailed(body: &str) -> (Vec<ObjectMeta>, Option<String>) {
625 let blocks = extract_all_tags(body, "Contents");
626 let entries = blocks
627 .into_iter()
628 .filter_map(|block| {
629 let key = extract_first_text(&block, "Key")?;
630 let size = extract_first_tag(&block, "Size")?.trim().parse::<u64>().ok()?;
631 let last_modified = extract_first_text(&block, "LastModified")?;
632 Some(ObjectMeta { key, size, last_modified })
633 })
634 .collect();
635 let next = extract_first_text(body, "NextContinuationToken");
636 let truncated = extract_first_tag(body, "IsTruncated")
637 .map(|v| v.trim().eq_ignore_ascii_case("true"))
638 .unwrap_or(false);
639 (entries, if truncated { next } else { None })
640}
641
642fn extract_all_tags(body: &str, tag: &str) -> Vec<String> {
643 let open = format!("<{tag}>");
644 let close = format!("</{tag}>");
645 let mut out = Vec::new();
646 let mut search = body;
647 while let Some(start) = search.find(&open) {
648 let content_start = start + open.len();
649 if let Some(end) = search[content_start..].find(&close) {
650 out.push(search[content_start..content_start + end].to_string());
651 search = &search[content_start + end + close.len()..];
652 } else {
653 break;
654 }
655 }
656 out
657}
658
659fn extract_first_tag(body: &str, tag: &str) -> Option<String> {
660 extract_all_tags(body, tag).into_iter().next()
661}
662
663fn extract_first_text(body: &str, tag: &str) -> Option<String> {
669 extract_first_tag(body, tag).map(|raw| decode_xml_entities(&raw))
670}
671
672fn decode_xml_entities(raw: &str) -> String {
686 if !raw.contains('&') {
687 return raw.to_string();
688 }
689 let mut out = String::with_capacity(raw.len());
690 let mut rest = raw;
691 while let Some(amp) = rest.find('&') {
692 out.push_str(&rest[..amp]);
693 let tail = &rest[amp..];
694 let Some(semi) = tail.find(';').filter(|&i| i <= 10) else {
695 out.push('&');
696 rest = &tail[1..];
697 continue;
698 };
699 let entity = &tail[1..semi];
700 let decoded = match entity {
701 "amp" => Some('&'),
702 "lt" => Some('<'),
703 "gt" => Some('>'),
704 "quot" => Some('"'),
705 "apos" => Some('\''),
706 _ => entity
707 .strip_prefix('#')
708 .and_then(|n| match n.strip_prefix(['x', 'X']) {
709 Some(hex) => u32::from_str_radix(hex, 16).ok(),
710 None => n.parse::<u32>().ok(),
711 })
712 .and_then(char::from_u32),
713 };
714 match decoded {
715 Some(c) => {
716 out.push(c);
717 rest = &tail[semi + 1..];
718 }
719 None => {
720 out.push('&');
721 rest = &tail[1..];
722 }
723 }
724 }
725 out.push_str(rest);
726 out
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn with_endpoint_redirects_every_url_and_leaves_r2_alone() {
735 let store = R2ObjectStore::new("acct", "yah-dev", "k", "s").unwrap();
736 assert_eq!(
737 store.object_url("yah/index.json"),
738 "https://acct.r2.cloudflarestorage.com/yah-dev/yah/index.json"
739 );
740
741 let pond = R2ObjectStore::new("pond", "yah-dev", "k", "s")
743 .unwrap()
744 .with_endpoint("http://127.0.0.1:9000");
745 assert_eq!(
746 pond.object_url("yah/index.json"),
747 "http://127.0.0.1:9000/yah-dev/yah/index.json"
748 );
749 assert_eq!(pond.bucket_url(), "http://127.0.0.1:9000/yah-dev");
750 }
751
752 #[test]
753 fn with_endpoint_normalizes_trailing_slash_and_ignores_empty() {
754 let s = R2ObjectStore::new("acct", "b", "k", "s")
755 .unwrap()
756 .with_endpoint("http://127.0.0.1:9000/");
757 assert_eq!(s.object_url("k1"), "http://127.0.0.1:9000/b/k1");
758 let s = R2ObjectStore::new("acct", "b", "k", "s")
761 .unwrap()
762 .with_endpoint("");
763 assert_eq!(
764 s.object_url("k1"),
765 "https://acct.r2.cloudflarestorage.com/b/k1"
766 );
767 }
768
769 fn one_shot_http() -> (String, std::thread::JoinHandle<String>) {
775 use std::io::{BufRead, BufReader, Read, Write};
776
777 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
778 let url = format!("http://{}", listener.local_addr().unwrap());
779 let handle = std::thread::spawn(move || {
780 let (stream, _) = listener.accept().unwrap();
781 let mut reader = BufReader::new(stream);
782 let mut head = String::new();
783 loop {
784 let mut line = String::new();
785 if reader.read_line(&mut line).unwrap() == 0 {
786 break;
787 }
788 let done = line == "\r\n";
789 head.push_str(&line);
790 if done {
791 break;
792 }
793 }
794 let len: usize = head
797 .lines()
798 .find_map(|l| {
799 l.strip_prefix("content-length: ")
800 .or_else(|| l.strip_prefix("Content-Length: "))
801 })
802 .and_then(|v| v.trim().parse().ok())
803 .unwrap_or(0);
804 let mut body = vec![0u8; len];
805 reader.read_exact(&mut body).unwrap();
806 reader
807 .into_inner()
808 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
809 .unwrap();
810 head
811 });
812 (url, handle)
813 }
814
815 #[test]
820 fn put_cached_sends_the_cache_control_header_on_the_wire() {
821 let (endpoint, server) = one_shot_http();
822 let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
823 .unwrap()
824 .with_endpoint(endpoint);
825
826 store
827 .put_cached(
828 "yah-desktop/latest.json",
829 b"{\"version\":\"0.8.22\"}".to_vec(),
830 crate::CACHE_CONTROL_NO_CACHE,
831 )
832 .unwrap();
833
834 let head = server.join().unwrap().to_lowercase();
835 assert!(
836 head.starts_with("put /yah-dev/yah-desktop/latest.json "),
837 "{head}"
838 );
839 assert!(head.contains("cache-control: no-cache, max-age=0\r\n"), "{head}");
840 assert!(
842 head.contains("signedheaders=cache-control;content-length;content-type;host;"),
843 "{head}"
844 );
845 }
846
847 #[test]
854 fn a_colon_key_goes_on_the_wire_percent_encoded() {
855 let (endpoint, server) = one_shot_http();
856 let store = R2ObjectStore::new("acct", "yah-cr", "AK", "SK")
857 .unwrap()
858 .with_endpoint(endpoint);
859
860 store
861 .put("blobs/sha256:deadbeef", b"layer".to_vec())
862 .unwrap();
863
864 let head = server.join().unwrap();
865 let request_line = head.lines().next().unwrap();
866 assert_eq!(
867 request_line, "PUT /yah-cr/blobs/sha256%3Adeadbeef HTTP/1.1",
868 "full head: {head}"
869 );
870 assert!(!request_line.contains("%3a"), "{request_line}");
873 }
874
875 #[test]
878 fn an_unreserved_key_reaches_the_wire_unchanged() {
879 let (endpoint, server) = one_shot_http();
880 let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
881 .unwrap()
882 .with_endpoint(endpoint);
883
884 store
885 .put("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz", b"x".to_vec())
886 .unwrap();
887
888 let head = server.join().unwrap();
889 assert_eq!(
890 head.lines().next().unwrap(),
891 "PUT /yah-dev/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz HTTP/1.1",
892 "full head: {head}"
893 );
894 }
895
896 #[test]
903 fn delete_signs_without_content_length_and_sends_none() {
904 let (endpoint, server) = one_shot_http();
905 let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
906 .unwrap()
907 .with_endpoint(endpoint);
908
909 store.delete("some/blob.bin").unwrap();
910
911 let head = server.join().unwrap().to_lowercase();
912 assert!(head.starts_with("delete /yah-dev/some/blob.bin "), "{head}");
913 assert!(
914 head.contains("signedheaders=host;x-amz-content-sha256;x-amz-date"),
915 "content-length must NOT be signed on a body-less DELETE: {head}"
916 );
917 assert!(!head.contains("content-length"), "{head}");
920 }
921
922 #[test]
926 fn a_plain_put_sends_no_cache_control_header() {
927 let (endpoint, server) = one_shot_http();
928 let store = R2ObjectStore::new("acct", "yah-dev", "AK", "SK")
929 .unwrap()
930 .with_endpoint(endpoint);
931
932 store.put("some/blob.bin", b"bytes".to_vec()).unwrap();
933
934 let head = server.join().unwrap().to_lowercase();
935 assert!(!head.contains("cache-control"), "{head}");
936 }
937
938 #[test]
939 fn parse_list_v2_extracts_keys() {
940 let body = r#"<?xml version="1.0" encoding="UTF-8"?>
941 <ListBucketResult>
942 <IsTruncated>false</IsTruncated>
943 <Contents><Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key></Contents>
944 <Contents><Key>yubaba/release-manifest.json</Key></Contents>
945 </ListBucketResult>"#;
946 let (keys, next) = parse_list_v2(body);
947 assert_eq!(
948 keys,
949 vec![
950 "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz".to_string(),
951 "yubaba/release-manifest.json".to_string(),
952 ]
953 );
954 assert!(next.is_none());
955 }
956
957 #[test]
958 fn parse_list_v2_returns_continuation_when_truncated() {
959 let body = r#"<ListBucketResult>
960 <IsTruncated>true</IsTruncated>
961 <NextContinuationToken>abc123</NextContinuationToken>
962 <Contents><Key>a</Key></Contents>
963 </ListBucketResult>"#;
964 let (keys, next) = parse_list_v2(body);
965 assert_eq!(keys, vec!["a".to_string()]);
966 assert_eq!(next.as_deref(), Some("abc123"));
967 }
968
969 #[test]
970 fn parse_list_v2_ignores_token_when_not_truncated() {
971 let body = r#"<ListBucketResult>
974 <IsTruncated>false</IsTruncated>
975 <NextContinuationToken>stale</NextContinuationToken>
976 <Contents><Key>a</Key></Contents>
977 </ListBucketResult>"#;
978 let (_, next) = parse_list_v2(body);
979 assert!(next.is_none());
980 }
981
982 #[test]
983 fn parse_list_v2_detailed_extracts_size_and_mtime() {
984 let body = r#"<?xml version="1.0" encoding="UTF-8"?>
985 <ListBucketResult>
986 <IsTruncated>false</IsTruncated>
987 <Contents>
988 <Key>yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz</Key>
989 <LastModified>2026-06-08T20:14:32.000Z</LastModified>
990 <ETag>"abc"</ETag>
991 <Size>4823104</Size>
992 <StorageClass>STANDARD</StorageClass>
993 </Contents>
994 <Contents>
995 <Key>yubaba/release-manifest.json</Key>
996 <LastModified>2026-06-08T20:14:35.000Z</LastModified>
997 <Size>412</Size>
998 </Contents>
999 </ListBucketResult>"#;
1000 let (entries, next) = parse_list_v2_detailed(body);
1001 assert_eq!(entries.len(), 2);
1002 assert_eq!(entries[0].key, "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz");
1003 assert_eq!(entries[0].size, 4823104);
1004 assert_eq!(entries[0].last_modified, "2026-06-08T20:14:32.000Z");
1005 assert_eq!(entries[1].key, "yubaba/release-manifest.json");
1006 assert_eq!(entries[1].size, 412);
1007 assert!(next.is_none());
1008 }
1009
1010 #[test]
1011 fn r2_object_store_constructs_with_explicit_keys() {
1012 let s = R2ObjectStore::new("acct", "yah-dev", "AK", "SK").unwrap();
1013 assert_eq!(s.object_url("k"), "https://acct.r2.cloudflarestorage.com/yah-dev/k");
1014 assert_eq!(s.bucket_url(), "https://acct.r2.cloudflarestorage.com/yah-dev");
1015 }
1016
1017 #[test]
1018 fn object_url_preserves_slashes_in_key() {
1019 let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
1020 assert_eq!(
1021 s.object_url("yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"),
1022 "https://acct.r2.cloudflarestorage.com/b/yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz"
1023 );
1024 }
1025
1026 #[test]
1033 fn list_keys_are_xml_decoded() {
1034 let body = "<ListBucketResult>\
1035 <Contents><Key>a&b.json</Key><Size>1</Size><LastModified>t</LastModified></Contents>\
1036 <Contents><Key>x<y>z</Key><Size>2</Size><LastModified>t</LastModified></Contents>\
1037 <Contents><Key>q"r's</Key><Size>3</Size><LastModified>t</LastModified></Contents>\
1038 <Contents><Key>n mA</Key><Size>4</Size><LastModified>t</LastModified></Contents>\
1039 <IsTruncated>false</IsTruncated></ListBucketResult>";
1040 let (keys, next) = parse_list_v2(body);
1041 assert_eq!(keys, vec!["a&b.json", "x<y>z", "q\"r's", "n\rmA"]);
1042 assert!(next.is_none());
1043 let (entries, _) = parse_list_v2_detailed(body);
1046 assert_eq!(entries.len(), 4);
1047 assert_eq!(entries[0].key, "a&b.json");
1048 assert_eq!(entries[0].size, 1);
1049 }
1050
1051 #[test]
1053 fn xml_decode_passes_through_a_non_entity_ampersand() {
1054 assert_eq!(decode_xml_entities("a & b"), "a & b");
1055 assert_eq!(decode_xml_entities("¬anentity;"), "¬anentity;");
1056 assert_eq!(decode_xml_entities("plain/key.json"), "plain/key.json");
1057 assert_eq!(decode_xml_entities("&&"), "&&");
1058 }
1059
1060 #[test]
1066 fn object_url_encodes_a_colon_in_the_key() {
1067 let s = R2ObjectStore::new("acct", "yah-cr", "AK", "SK").unwrap();
1068 assert_eq!(
1069 s.object_url("blobs/sha256:deadbeef"),
1070 "https://acct.r2.cloudflarestorage.com/yah-cr/blobs/sha256%3Adeadbeef"
1071 );
1072 assert_eq!(s.locate("blobs/sha256:deadbeef"), s.object_url("blobs/sha256:deadbeef"));
1075 }
1076
1077 #[test]
1080 fn object_url_leaves_unreserved_keys_untouched() {
1081 let s = R2ObjectStore::new("acct", "b", "AK", "SK").unwrap();
1082 for key in [
1083 "k",
1084 "yah/index.json",
1085 "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz",
1086 "releases/v1.2.3-rc.1/yah_1.2.3_aarch64.dmg",
1087 ] {
1088 assert_eq!(
1089 s.object_url(key),
1090 format!("https://acct.r2.cloudflarestorage.com/b/{key}")
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn content_type_inferred_from_extension() {
1097 assert_eq!(
1098 content_type_for_key("yah-marketing/cloud/index.html"),
1099 "text/html; charset=utf-8"
1100 );
1101 assert_eq!(content_type_for_key("app.css"), "text/css; charset=utf-8");
1102 assert_eq!(content_type_for_key("bundle.mjs"), "text/javascript; charset=utf-8");
1103 assert_eq!(content_type_for_key("illustrations/horse.webp"), "image/webp");
1104 assert_eq!(content_type_for_key("manifest.json"), "application/json");
1105 assert_eq!(content_type_for_key("pointers/releases"), DEFAULT_CONTENT_TYPE);
1107 assert_eq!(content_type_for_key("v1.2/binary"), DEFAULT_CONTENT_TYPE);
1108 }
1109}