1use anyhow::{Context, Result};
26use hmac::{Hmac, Mac};
27use reqwest::header::HeaderMap;
28use sha2::{Digest, Sha256};
29
30type HmacSha256 = Hmac<Sha256>;
31
32fn canonical_host(parsed: &reqwest::Url) -> Result<String> {
44 let host = parsed.host_str().context("no host in S3 URL")?;
45 Ok(match parsed.port() {
46 Some(port) => format!("{host}:{port}"),
47 None => host.to_string(),
48 })
49}
50
51pub fn uri_encode_key(key: &str) -> String {
72 let mut out = String::with_capacity(key.len());
73 for byte in key.bytes() {
74 match byte {
75 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
76 out.push(byte as char);
77 }
78 _ => out.push_str(&format!("%{byte:02X}")),
79 }
80 }
81 out
82}
83
84fn is_aws_uri_encoded(path: &str) -> bool {
91 let bytes = path.as_bytes();
92 let mut i = 0;
93 while i < bytes.len() {
94 match bytes[i] {
95 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => i += 1,
96 b'%' => {
97 let hex = bytes.get(i + 1..i + 3);
98 match hex {
99 Some(h) if h.iter().all(|c| c.is_ascii_digit() || (b'A'..=b'F').contains(c)) => {
100 i += 3
101 }
102 _ => return false,
103 }
104 }
105 _ => return false,
106 }
107 }
108 true
109}
110
111fn canonical_uri(parsed: &reqwest::Url) -> String {
117 let uri = parsed.path().to_string();
118 debug_assert!(
119 is_aws_uri_encoded(&uri),
120 "R630-B1: S3 URL paths must be AWS-UriEncoded before they reach the \
121 signer — build the URL with `uri_encode_key(key)`. Got: {uri}"
122 );
123 uri
124}
125
126pub fn sign_s3_empty_body(
132 method: &str,
133 url: &str,
134 region: &str,
135 access_key: &str,
136 secret_key: &str,
137) -> Result<HeaderMap> {
138 let now = chrono::Utc::now();
139 let date = now.format("%Y%m%d").to_string();
140 let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
141
142 let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
143 let host = canonical_host(&parsed)?;
144 let uri = canonical_uri(&parsed);
145
146 let empty_hash = {
147 let mut h = Sha256::new();
148 h.update(b"");
149 hex::encode(h.finalize())
150 };
151
152 let canonical_headers = format!(
153 "content-length:0\nhost:{host}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
154 );
155 let signed_headers = "content-length;host;x-amz-content-sha256;x-amz-date";
156
157 let canonical_request =
158 format!("{method}\n{uri}\n\n{canonical_headers}\n{signed_headers}\n{empty_hash}");
159
160 let cr_hash = {
161 let mut h = Sha256::new();
162 h.update(canonical_request.as_bytes());
163 hex::encode(h.finalize())
164 };
165
166 let credential_scope = format!("{date}/{region}/s3/aws4_request");
167 let string_to_sign =
168 format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
169
170 let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
171 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
172 mac.update(data);
173 mac.finalize().into_bytes().to_vec()
174 };
175
176 let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
177 let date_region_key = hmac_sign(&date_key, region.as_bytes());
178 let date_region_service_key = hmac_sign(&date_region_key, b"s3");
179 let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
180 let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
181
182 let authorization = format!(
183 "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
184 SignedHeaders={signed_headers}, Signature={signature}"
185 );
186
187 let mut headers = HeaderMap::new();
188 headers.insert("host", host.parse()?);
189 headers.insert("x-amz-date", datetime.parse()?);
190 headers.insert("x-amz-content-sha256", empty_hash.parse()?);
191 headers.insert("content-length", "0".parse()?);
192 headers.insert("authorization", authorization.parse()?);
193 Ok(headers)
194}
195
196pub fn sign_s3_put_bucket(
197 url: &str,
198 region: &str,
199 access_key: &str,
200 secret_key: &str,
201) -> Result<HeaderMap> {
202 sign_s3_empty_body("PUT", url, region, access_key, secret_key)
203}
204
205pub fn sign_s3_head_bucket(
206 url: &str,
207 region: &str,
208 access_key: &str,
209 secret_key: &str,
210) -> Result<HeaderMap> {
211 sign_s3_empty_body("HEAD", url, region, access_key, secret_key)
212}
213
214pub fn sign_s3_delete_bucket(
215 url: &str,
216 region: &str,
217 access_key: &str,
218 secret_key: &str,
219) -> Result<HeaderMap> {
220 sign_s3_empty_body("DELETE", url, region, access_key, secret_key)
221}
222
223pub fn sign_s3_get_with_query(
233 url: &str,
234 canonical_query: &str,
235 region: &str,
236 access_key: &str,
237 secret_key: &str,
238) -> Result<HeaderMap> {
239 sign_s3_no_body("GET", url, canonical_query, region, access_key, secret_key)
240}
241
242pub fn sign_s3_no_body(
255 method: &str,
256 url: &str,
257 canonical_query: &str,
258 region: &str,
259 access_key: &str,
260 secret_key: &str,
261) -> Result<HeaderMap> {
262 let now = chrono::Utc::now();
263 let date = now.format("%Y%m%d").to_string();
264 let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
265
266 let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
267 let host = canonical_host(&parsed)?;
268 let uri = canonical_uri(&parsed);
269
270 let empty_hash = {
271 let mut h = Sha256::new();
272 h.update(b"");
273 hex::encode(h.finalize())
274 };
275
276 let canonical_headers = format!(
277 "host:{host}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
278 );
279 let signed_headers = "host;x-amz-content-sha256;x-amz-date";
280
281 let canonical_request = format!(
282 "{method}\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{empty_hash}"
283 );
284
285 let cr_hash = {
286 let mut h = Sha256::new();
287 h.update(canonical_request.as_bytes());
288 hex::encode(h.finalize())
289 };
290
291 let credential_scope = format!("{date}/{region}/s3/aws4_request");
292 let string_to_sign =
293 format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
294
295 let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
296 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
297 mac.update(data);
298 mac.finalize().into_bytes().to_vec()
299 };
300
301 let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
302 let date_region_key = hmac_sign(&date_key, region.as_bytes());
303 let date_region_service_key = hmac_sign(&date_region_key, b"s3");
304 let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
305 let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
306
307 let authorization = format!(
308 "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
309 SignedHeaders={signed_headers}, Signature={signature}"
310 );
311
312 let mut headers = HeaderMap::new();
313 headers.insert("host", host.parse()?);
314 headers.insert("x-amz-date", datetime.parse()?);
315 headers.insert("x-amz-content-sha256", empty_hash.parse()?);
316 headers.insert("authorization", authorization.parse()?);
317 Ok(headers)
318}
319
320#[derive(Debug, Clone, Default)]
327pub struct S3PutOptions<'a> {
328 pub content_type: &'a str,
330 pub blake3_meta: Option<&'a str>,
332 pub cache_control: Option<&'a str>,
340}
341
342pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
346
347pub const CACHE_CONTROL_NO_CACHE: &str = "no-cache, max-age=0";
350
351pub fn sign_s3_put_object(
366 url: &str,
367 body_sha256: &str,
368 content_type: &str,
369 content_length: usize,
370 region: &str,
371 access_key: &str,
372 secret_key: &str,
373 blake3_meta: Option<&str>,
374) -> Result<HeaderMap> {
375 sign_s3_put_object_with(
376 url,
377 body_sha256,
378 content_length,
379 region,
380 access_key,
381 secret_key,
382 &S3PutOptions {
383 content_type,
384 blake3_meta,
385 cache_control: None,
386 },
387 )
388}
389
390pub fn sign_s3_put_object_with(
392 url: &str,
393 body_sha256: &str,
394 content_length: usize,
395 region: &str,
396 access_key: &str,
397 secret_key: &str,
398 opts: &S3PutOptions<'_>,
399) -> Result<HeaderMap> {
400 let S3PutOptions {
401 content_type,
402 blake3_meta,
403 cache_control,
404 } = *opts;
405 let now = chrono::Utc::now();
406 let date = now.format("%Y%m%d").to_string();
407 let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
408
409 let parsed = reqwest::Url::parse(url).context("parsing S3 object URL")?;
410 let host = canonical_host(&parsed)?;
411 let uri = canonical_uri(&parsed);
412
413 let mut signed: Vec<(&str, String)> = Vec::with_capacity(7);
421 if let Some(cc) = cache_control {
422 signed.push(("cache-control", cc.to_string()));
423 }
424 signed.push(("content-length", content_length.to_string()));
425 signed.push(("content-type", content_type.to_string()));
426 signed.push(("host", host.clone()));
427 signed.push(("x-amz-content-sha256", body_sha256.to_string()));
428 signed.push(("x-amz-date", datetime.clone()));
429 if let Some(b3) = blake3_meta {
430 signed.push(("x-amz-meta-blake3", b3.to_string()));
431 }
432 debug_assert!(
433 signed.windows(2).all(|w| w[0].0 < w[1].0),
434 "canonical headers must be lexicographically ordered: {:?}",
435 signed.iter().map(|(n, _)| *n).collect::<Vec<_>>()
436 );
437
438 let canonical_headers: String = signed
439 .iter()
440 .map(|(name, value)| format!("{name}:{value}\n"))
441 .collect();
442 let signed_headers = signed
443 .iter()
444 .map(|(name, _)| *name)
445 .collect::<Vec<_>>()
446 .join(";");
447 let signed_headers = signed_headers.as_str();
448
449 let canonical_request =
450 format!("PUT\n{uri}\n\n{canonical_headers}\n{signed_headers}\n{body_sha256}");
451
452 let cr_hash = {
453 let mut h = Sha256::new();
454 h.update(canonical_request.as_bytes());
455 hex::encode(h.finalize())
456 };
457
458 let credential_scope = format!("{date}/{region}/s3/aws4_request");
459 let string_to_sign =
460 format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
461
462 let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
463 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
464 mac.update(data);
465 mac.finalize().into_bytes().to_vec()
466 };
467
468 let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
469 let date_region_key = hmac_sign(&date_key, region.as_bytes());
470 let date_region_service_key = hmac_sign(&date_region_key, b"s3");
471 let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
472 let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
473
474 let authorization = format!(
475 "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
476 SignedHeaders={signed_headers}, Signature={signature}"
477 );
478
479 let mut headers = HeaderMap::new();
480 headers.insert("host", host.parse()?);
481 headers.insert("x-amz-date", datetime.parse()?);
482 headers.insert("x-amz-content-sha256", body_sha256.parse()?);
483 headers.insert("content-length", content_length.to_string().parse()?);
484 headers.insert("content-type", content_type.parse()?);
485 if let Some(cc) = cache_control {
486 headers.insert("cache-control", cc.parse()?);
487 }
488 if let Some(b3) = blake3_meta {
489 headers.insert("x-amz-meta-blake3", b3.parse()?);
490 }
491 headers.insert("authorization", authorization.parse()?);
492 Ok(headers)
493}
494
495pub fn sign_s3_put_bucket_policy(
502 url: &str,
503 region: &str,
504 access_key: &str,
505 secret_key: &str,
506 policy_json: &[u8],
507) -> Result<HeaderMap> {
508 let now = chrono::Utc::now();
509 let date = now.format("%Y%m%d").to_string();
510 let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
511
512 let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
513 let host = canonical_host(&parsed)?;
514 let uri = canonical_uri(&parsed);
515 let canonical_query = "policy=";
516 let content_length = policy_json.len();
517
518 let body_hash = {
519 let mut h = Sha256::new();
520 h.update(policy_json);
521 hex::encode(h.finalize())
522 };
523
524 let canonical_headers = format!(
525 "content-length:{content_length}\ncontent-type:application/json\nhost:{host}\nx-amz-content-sha256:{body_hash}\nx-amz-date:{datetime}\n"
526 );
527 let signed_headers = "content-length;content-type;host;x-amz-content-sha256;x-amz-date";
528
529 let canonical_request =
530 format!("PUT\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{body_hash}");
531
532 let cr_hash = {
533 let mut h = Sha256::new();
534 h.update(canonical_request.as_bytes());
535 hex::encode(h.finalize())
536 };
537
538 let credential_scope = format!("{date}/{region}/s3/aws4_request");
539 let string_to_sign =
540 format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
541
542 let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
543 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
544 mac.update(data);
545 mac.finalize().into_bytes().to_vec()
546 };
547
548 let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
549 let date_region_key = hmac_sign(&date_key, region.as_bytes());
550 let date_region_service_key = hmac_sign(&date_region_key, b"s3");
551 let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
552 let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
553
554 let authorization = format!(
555 "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
556 SignedHeaders={signed_headers}, Signature={signature}"
557 );
558
559 let mut headers = HeaderMap::new();
560 headers.insert("host", host.parse()?);
561 headers.insert("x-amz-date", datetime.parse()?);
562 headers.insert("x-amz-content-sha256", body_hash.parse()?);
563 headers.insert("content-length", content_length.to_string().parse()?);
564 headers.insert("content-type", "application/json".parse()?);
565 headers.insert("authorization", authorization.parse()?);
566 Ok(headers)
567}
568
569pub fn sign_s3_put_bucket_acl(
576 url: &str,
577 region: &str,
578 access_key: &str,
579 secret_key: &str,
580 acl: &str,
581) -> Result<HeaderMap> {
582 let now = chrono::Utc::now();
583 let date = now.format("%Y%m%d").to_string();
584 let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
585
586 let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
587 let host = canonical_host(&parsed)?;
588 let uri = canonical_uri(&parsed);
589 let canonical_query = "acl=";
590
591 let empty_hash = {
592 let mut h = Sha256::new();
593 h.update(b"");
594 hex::encode(h.finalize())
595 };
596
597 let canonical_headers = format!(
599 "content-length:0\nhost:{host}\nx-amz-acl:{acl}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
600 );
601 let signed_headers = "content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date";
602
603 let canonical_request =
604 format!("PUT\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{empty_hash}");
605
606 let cr_hash = {
607 let mut h = Sha256::new();
608 h.update(canonical_request.as_bytes());
609 hex::encode(h.finalize())
610 };
611
612 let credential_scope = format!("{date}/{region}/s3/aws4_request");
613 let string_to_sign =
614 format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
615
616 let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
617 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
618 mac.update(data);
619 mac.finalize().into_bytes().to_vec()
620 };
621
622 let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
623 let date_region_key = hmac_sign(&date_key, region.as_bytes());
624 let date_region_service_key = hmac_sign(&date_region_key, b"s3");
625 let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
626 let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
627
628 let authorization = format!(
629 "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
630 SignedHeaders={signed_headers}, Signature={signature}"
631 );
632
633 let mut headers = HeaderMap::new();
634 headers.insert("host", host.parse()?);
635 headers.insert("x-amz-date", datetime.parse()?);
636 headers.insert("x-amz-content-sha256", empty_hash.parse()?);
637 headers.insert("content-length", "0".parse()?);
638 headers.insert("x-amz-acl", acl.parse()?);
639 headers.insert("authorization", authorization.parse()?);
640 Ok(headers)
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
652 fn uri_encode_key_escapes_colon_and_the_other_sub_delims() {
653 assert_eq!(uri_encode_key("sha256:abc"), "sha256%3Aabc");
654 assert_eq!(
656 uri_encode_key("a@b+c,d=e&f;g$h!i'j(k)l*m[n]o"),
657 "a%40b%2Bc%2Cd%3De%26f%3Bg%24h%21i%27j%28k%29l%2Am%5Bn%5Do"
658 );
659 assert_eq!(uri_encode_key("my file.txt"), "my%20file.txt");
661 assert_eq!(uri_encode_key("\x1f"), "%1F");
664 assert_eq!(uri_encode_key("é"), "%C3%A9");
666 }
667
668 #[test]
672 fn uri_encode_key_leaves_todays_keys_byte_identical() {
673 for key in [
674 "yah/index.json",
675 "yubaba/0.8.9/x86_64-unknown-linux-musl/yubaba.tar.gz",
676 "_yah-manifest.json",
677 "releases/v1.2.3-rc.1/yah_1.2.3_aarch64.dmg",
678 "a~b-c_d.e/f",
679 "",
680 ] {
681 assert_eq!(uri_encode_key(key), key, "key must not change: {key}");
682 }
683 assert_eq!(uri_encode_key("100%25"), "100%2525");
686 }
687
688 #[test]
691 fn is_aws_uri_encoded_accepts_encoded_and_rejects_raw() {
692 assert!(is_aws_uri_encoded("/bucket/sha256%3Aabc"));
693 assert!(is_aws_uri_encoded("/bucket/plain/key.tar.gz"));
694 assert!(is_aws_uri_encoded("/"));
695 assert!(!is_aws_uri_encoded("/bucket/sha256:abc"));
696 assert!(!is_aws_uri_encoded("/bucket/sha256%3aabc"));
698 assert!(!is_aws_uri_encoded("/bucket/x%3"));
700 assert!(!is_aws_uri_encoded("/bucket/x%ZZ"));
701 }
702
703 #[test]
708 fn signing_an_encoded_colon_key_differs_from_signing_it_raw() {
709 let sign = |path: &str| {
710 sign_s3_no_body(
711 "GET",
712 &format!("https://acct.r2.cloudflarestorage.com/yah-cr/{path}"),
713 "",
714 "auto",
715 "AK",
716 "SK",
717 )
718 .unwrap()
719 .get("authorization")
720 .unwrap()
721 .to_str()
722 .unwrap()
723 .to_string()
724 };
725 let encoded = sign(&uri_encode_key("blobs/sha256:deadbeef"));
726 let other = sign(&uri_encode_key("blobs/sha256-deadbeef"));
730 assert_ne!(encoded, other);
731 }
732
733 #[test]
738 fn canonical_host_carries_a_nondefault_port() {
739 let u = reqwest::Url::parse("http://127.0.0.1:9000/yah-dev/yah/index.json").unwrap();
740 assert_eq!(canonical_host(&u).unwrap(), "127.0.0.1:9000");
741 let u = reqwest::Url::parse("https://acct.r2.cloudflarestorage.com/yah-dev/k").unwrap();
744 assert_eq!(canonical_host(&u).unwrap(), "acct.r2.cloudflarestorage.com");
745 let u = reqwest::Url::parse("https://acct.r2.cloudflarestorage.com:443/yah-dev/k").unwrap();
746 assert_eq!(canonical_host(&u).unwrap(), "acct.r2.cloudflarestorage.com");
747 }
748
749 #[test]
750 fn signed_headers_include_the_port_for_a_local_endpoint() {
751 let headers = sign_s3_put_object(
752 "http://127.0.0.1:9000/yah-dev/yah/index.json",
753 &"0".repeat(64),
754 "application/json",
755 10,
756 "auto",
757 "AK",
758 "SK",
759 None,
760 )
761 .unwrap();
762 assert_eq!(
763 headers.get("host").unwrap().to_str().unwrap(),
764 "127.0.0.1:9000"
765 );
766 }
767
768 #[test]
769 fn sign_produces_required_headers() {
770 let headers = sign_s3_put_bucket(
771 "https://fsn1.your-objectstorage.com/test-bucket",
772 "fsn1",
773 "AK",
774 "SK",
775 )
776 .unwrap();
777 assert!(headers.contains_key("authorization"));
778 assert!(headers.contains_key("x-amz-date"));
779 assert!(headers.contains_key("x-amz-content-sha256"));
780 let auth = headers.get("authorization").unwrap().to_str().unwrap();
781 assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
782 assert!(
783 auth.contains("SignedHeaders=content-length;host;x-amz-content-sha256;x-amz-date")
784 );
785 }
786
787 #[test]
792 fn put_object_signs_the_blake3_metadata_header() {
793 let b3 = "d".repeat(64);
794 let headers = sign_s3_put_object(
795 "https://acct.r2.cloudflarestorage.com/yah-dev/some/key.tar.gz",
796 &"0".repeat(64),
797 "application/octet-stream",
798 123,
799 "auto",
800 "AK",
801 "SK",
802 Some(&b3),
803 )
804 .unwrap();
805
806 assert_eq!(headers.get("x-amz-meta-blake3").unwrap().to_str().unwrap(), b3);
807 let auth = headers.get("authorization").unwrap().to_str().unwrap();
808 assert!(
809 auth.contains(
810 "SignedHeaders=content-length;content-type;host;x-amz-content-sha256;\
811 x-amz-date;x-amz-meta-blake3"
812 ),
813 "metadata header must be inside SignedHeaders, got: {auth}"
814 );
815 }
816
817 #[test]
820 fn put_object_without_metadata_keeps_the_original_signed_header_set() {
821 let headers = sign_s3_put_object(
822 "https://acct.r2.cloudflarestorage.com/yah-dev/some/key.bin",
823 &"0".repeat(64),
824 "application/octet-stream",
825 7,
826 "auto",
827 "AK",
828 "SK",
829 None,
830 )
831 .unwrap();
832
833 assert!(!headers.contains_key("x-amz-meta-blake3"));
834 let auth = headers.get("authorization").unwrap().to_str().unwrap();
835 assert!(
836 auth.contains(
837 "SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date,"
838 ),
839 "unstamped PUT must keep the original signed-header set, got: {auth}"
840 );
841 }
842
843 #[test]
848 fn put_object_signs_cache_control_ahead_of_content_length() {
849 let headers = sign_s3_put_object_with(
850 "https://acct.r2.cloudflarestorage.com/yah-dev/yah-desktop/latest.json",
851 &"0".repeat(64),
852 42,
853 "auto",
854 "AK",
855 "SK",
856 &S3PutOptions {
857 content_type: "application/json",
858 blake3_meta: None,
859 cache_control: Some(CACHE_CONTROL_NO_CACHE),
860 },
861 )
862 .unwrap();
863
864 assert_eq!(
865 headers.get("cache-control").unwrap().to_str().unwrap(),
866 "no-cache, max-age=0"
867 );
868 let auth = headers.get("authorization").unwrap().to_str().unwrap();
869 assert!(
870 auth.contains(
871 "SignedHeaders=cache-control;content-length;content-type;host;\
872 x-amz-content-sha256;x-amz-date,"
873 ),
874 "cache-control must be signed, and first, got: {auth}"
875 );
876 }
877
878 #[test]
881 fn put_object_signs_cache_control_and_blake3_together_in_order() {
882 let b3 = "e".repeat(64);
883 let headers = sign_s3_put_object_with(
884 "https://acct.r2.cloudflarestorage.com/yah-dev/a/v1.2.3/yah.tar.gz",
885 &"0".repeat(64),
886 9,
887 "auto",
888 "AK",
889 "SK",
890 &S3PutOptions {
891 content_type: "application/octet-stream",
892 blake3_meta: Some(&b3),
893 cache_control: Some(CACHE_CONTROL_IMMUTABLE),
894 },
895 )
896 .unwrap();
897
898 let auth = headers.get("authorization").unwrap().to_str().unwrap();
899 assert!(
900 auth.contains(
901 "SignedHeaders=cache-control;content-length;content-type;host;\
902 x-amz-content-sha256;x-amz-date;x-amz-meta-blake3,"
903 ),
904 "got: {auth}"
905 );
906 assert_eq!(
907 headers.get("cache-control").unwrap().to_str().unwrap(),
908 "public, max-age=31536000, immutable"
909 );
910 }
911
912 #[test]
917 fn the_options_form_and_the_legacy_form_sign_identically() {
918 let url = "https://acct.r2.cloudflarestorage.com/yah-dev/k.bin";
919 let legacy =
920 sign_s3_put_object(url, &"0".repeat(64), "text/plain", 3, "auto", "AK", "SK", None)
921 .unwrap();
922 let with_opts = sign_s3_put_object_with(
923 url,
924 &"0".repeat(64),
925 3,
926 "auto",
927 "AK",
928 "SK",
929 &S3PutOptions {
930 content_type: "text/plain",
931 blake3_meta: None,
932 cache_control: None,
933 },
934 )
935 .unwrap();
936
937 assert!(!with_opts.contains_key("cache-control"));
938 let names = |h: &HeaderMap| {
942 let mut v: Vec<String> = h.keys().map(|k| k.as_str().to_string()).collect();
943 v.sort();
944 v
945 };
946 assert_eq!(names(&legacy), names(&with_opts));
947 }
948
949 #[test]
950 fn sign_get_with_query_signed_headers_omit_content_length() {
951 let headers = sign_s3_get_with_query(
952 "https://acct.r2.cloudflarestorage.com/yah-dev",
953 "list-type=2&prefix=whisper%2F",
954 "auto",
955 "AK",
956 "SK",
957 )
958 .unwrap();
959 let auth = headers.get("authorization").unwrap().to_str().unwrap();
960 assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
961 assert!(
962 auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
963 "GET with query must NOT include content-length in SignedHeaders: {auth}"
964 );
965 assert!(!headers.contains_key("content-length"));
966 }
967
968 #[test]
969 fn sign_no_body_get_object_omits_content_length() {
970 let headers = sign_s3_no_body(
973 "GET",
974 "https://acct.r2.cloudflarestorage.com/yah-dev/_yah-manifest.json",
975 "",
976 "auto",
977 "AK",
978 "SK",
979 )
980 .unwrap();
981 let auth = headers.get("authorization").unwrap().to_str().unwrap();
982 assert!(
983 auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
984 "object GET must NOT sign content-length: {auth}"
985 );
986 assert!(!headers.contains_key("content-length"));
987 }
988
989 #[test]
990 fn sign_no_body_head_uses_head_method() {
991 let head = sign_s3_no_body(
994 "HEAD",
995 "https://acct.r2.cloudflarestorage.com/yah-dev/k",
996 "",
997 "auto",
998 "AK",
999 "SK",
1000 )
1001 .unwrap();
1002 let get = sign_s3_no_body(
1003 "GET",
1004 "https://acct.r2.cloudflarestorage.com/yah-dev/k",
1005 "",
1006 "auto",
1007 "AK",
1008 "SK",
1009 )
1010 .unwrap();
1011 assert!(!head.contains_key("content-length"));
1012 assert_ne!(
1014 head.get("authorization").unwrap().to_str().unwrap(),
1015 get.get("authorization").unwrap().to_str().unwrap(),
1016 );
1017 }
1018
1019 #[test]
1020 fn sign_put_bucket_acl_includes_acl_header_and_query() {
1021 let headers = sign_s3_put_bucket_acl(
1022 "https://fsn1.your-objectstorage.com/test-bucket?acl",
1023 "fsn1",
1024 "AK",
1025 "SK",
1026 "public-read",
1027 )
1028 .unwrap();
1029 assert!(headers.contains_key("authorization"));
1030 assert!(headers.contains_key("x-amz-acl"));
1031 assert_eq!(headers.get("x-amz-acl").unwrap().to_str().unwrap(), "public-read");
1032 let auth = headers.get("authorization").unwrap().to_str().unwrap();
1033 assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
1034 assert!(auth.contains("x-amz-acl"));
1035 assert!(auth.contains("SignedHeaders=content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date"));
1036 }
1037}