1use crate::{error::SigningError, signed_url::UrlStyle, storage::client::ENCODED_CHARS};
16use google_cloud_auth::signer::Signer;
17use jiff::Timestamp;
18use percent_encoding::{AsciiSet, utf8_percent_encode};
19use sha2::{Digest, Sha256};
20use std::collections::BTreeMap;
21
22const PATH_ENCODE_SET: AsciiSet = ENCODED_CHARS.remove(b'/');
31
32#[derive(Debug)]
127pub struct SignedUrlBuilder {
128 scope: SigningScope,
129 method: http::Method,
130 expiration: std::time::Duration,
131 headers: BTreeMap<String, String>,
132 query_parameters: BTreeMap<String, String>,
133 endpoint: Option<String>,
134 universe_domain: Option<String>,
135 client_email: Option<String>,
136 timestamp: Timestamp,
137 url_style: UrlStyle,
138}
139
140#[derive(Debug)]
141enum SigningScope {
142 Bucket(String),
143 Object(String, String),
144}
145
146impl SigningScope {
147 fn check_bucket_name(&self) -> Result<(), SigningError> {
148 let bucket = match self {
149 SigningScope::Bucket(bucket) => bucket,
150 SigningScope::Object(bucket, _) => bucket,
151 };
152
153 bucket.strip_prefix("projects/_/buckets/").ok_or_else(|| {
154 SigningError::invalid_parameter(
155 "bucket",
156 format!(
157 "malformed bucket name, it must start with `projects/_/buckets/`: {bucket}"
158 ),
159 )
160 })?;
161
162 Ok(())
163 }
164
165 fn bucket_name(&self) -> String {
166 let bucket = match self {
167 SigningScope::Bucket(bucket) => bucket,
168 SigningScope::Object(bucket, _) => bucket,
169 };
170
171 bucket.trim_start_matches("projects/_/buckets/").to_string()
172 }
173
174 fn bucket_host(&self, host: &str, url_style: UrlStyle) -> String {
175 match url_style {
176 UrlStyle::PathStyle => host.to_string(),
177 UrlStyle::BucketBoundHostname => host.to_string(),
178 UrlStyle::VirtualHostedStyle => format!("{}.{host}", self.bucket_name()),
179 }
180 }
181
182 fn bucket_endpoint(&self, scheme: &str, host: &str, url_style: UrlStyle) -> String {
183 let bucket_host = self.bucket_host(host, url_style);
184 format!("{scheme}://{bucket_host}")
185 }
186
187 fn canonical_uri(&self, url_style: UrlStyle) -> String {
188 let bucket_name = self.bucket_name();
189 match self {
190 SigningScope::Object(_, object) => {
191 let encoded_object = utf8_percent_encode(object, &PATH_ENCODE_SET);
192 match url_style {
193 UrlStyle::PathStyle => {
194 format!("/{bucket_name}/{encoded_object}")
195 }
196 UrlStyle::BucketBoundHostname => {
197 format!("/{encoded_object}")
198 }
199 UrlStyle::VirtualHostedStyle => {
200 format!("/{encoded_object}")
201 }
202 }
203 }
204 SigningScope::Bucket(_) => match url_style {
205 UrlStyle::PathStyle => {
206 format!("/{bucket_name}")
207 }
208 UrlStyle::BucketBoundHostname => "".to_string(),
209 UrlStyle::VirtualHostedStyle => "".to_string(),
210 },
211 }
212 }
213
214 fn canonical_url(&self, scheme: &str, host: &str, url_style: UrlStyle) -> String {
215 let bucket_endpoint = self.bucket_endpoint(scheme, host, url_style);
216 let uri = self.canonical_uri(url_style);
217 format!("{bucket_endpoint}{uri}")
218 }
219}
220
221struct SigningComponents {
223 #[cfg(test)]
224 canonical_request: String,
225 #[cfg(test)]
226 string_to_sign: String,
227 signed_url: String,
228}
229
230impl SignedUrlBuilder {
231 fn new(scope: SigningScope) -> Self {
232 Self {
233 scope,
234 method: http::Method::GET,
235 expiration: std::time::Duration::from_secs(7 * 24 * 60 * 60), headers: BTreeMap::new(),
237 query_parameters: BTreeMap::new(),
238 endpoint: None,
239 universe_domain: None,
240 client_email: None,
241 timestamp: Timestamp::now(),
242 url_style: UrlStyle::PathStyle,
243 }
244 }
245
246 pub fn for_object<B, O>(bucket: B, object: O) -> Self
261 where
262 B: Into<String>,
263 O: Into<String>,
264 {
265 Self::new(SigningScope::Object(bucket.into(), object.into()))
266 }
267
268 pub fn for_bucket<B>(bucket: B) -> Self
283 where
284 B: Into<String>,
285 {
286 Self::new(SigningScope::Bucket(bucket.into()))
287 }
288
289 pub fn with_method(mut self, method: http::Method) -> Self {
307 self.method = method;
308 self
309 }
310
311 pub fn with_expiration(mut self, expiration: std::time::Duration) -> Self {
331 self.expiration = expiration;
332 self
333 }
334
335 pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
353 self.url_style = url_style;
354 self
355 }
356
357 pub fn with_header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
378 self.headers.insert(key.into().to_lowercase(), value.into());
379 self
380 }
381
382 pub fn with_query_param<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
399 self.query_parameters.insert(key.into(), value.into());
400 self
401 }
402
403 pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
421 self.endpoint = Some(endpoint.into());
422 self
423 }
424
425 pub fn with_universe_domain<V: Into<String>>(mut self, v: V) -> Self {
444 self.universe_domain = Some(v.into());
445 self
446 }
447
448 pub fn with_client_email<S: Into<String>>(mut self, client_email: S) -> Self {
466 self.client_email = Some(client_email.into());
467 self
468 }
469
470 fn resolve_endpoint_url(&self) -> Result<SignedUrlEndpoint, SigningError> {
471 let endpoint = self.resolve_endpoint();
472 let url = url::Url::parse(&endpoint)
473 .map_err(|e| SigningError::invalid_parameter("endpoint", e))?;
474 let host = url.host_str().ok_or_else(|| {
475 SigningError::invalid_parameter("endpoint", "Invalid endpoint, missing host.")
476 })?;
477
478 let path = url.path();
482 let scheme = format!("{}://", url.scheme());
483 let host_with_port = endpoint.trim_start_matches(&scheme).trim_end_matches(path);
484
485 Ok(SignedUrlEndpoint {
486 scheme: url.scheme().to_string(),
487 host_with_port: host_with_port.to_string(),
488 host: host.to_string(),
489 })
490 }
491
492 fn resolve_endpoint(&self) -> String {
493 match self.endpoint.as_ref() {
494 Some(e) if e.starts_with("http://") => e.clone(),
495 Some(e) if e.starts_with("https://") => e.clone(),
496 Some(e) => format!("https://{}", e),
497 None => {
498 let universe_domain = self.universe_domain.as_deref().unwrap_or("googleapis.com");
499 format!("https://storage.{universe_domain}")
500 }
501 }
502 }
503
504 fn canonicalize_header_value(value: &str) -> String {
505 let clean_value = value.replace("\t", " ").trim().to_string();
506 clean_value.split_whitespace().collect::<Vec<_>>().join(" ")
507 }
508
509 async fn sign_internal(
513 self,
514 signer: &Signer,
515 ) -> std::result::Result<SigningComponents, SigningError> {
516 self.scope.check_bucket_name()?;
518
519 let now = self.timestamp;
520 let request_timestamp = now.strftime("%Y%m%dT%H%M%SZ").to_string();
521 let datestamp = now.strftime("%Y%m%d");
522 let credential_scope = format!("{datestamp}/auto/storage/goog4_request");
523 let client_email = if let Some(email) = self.client_email.clone() {
524 email
525 } else {
526 signer.client_email().await.map_err(SigningError::signing)?
527 };
528 let credential = format!("{client_email}/{credential_scope}");
529
530 let endpoint = self.resolve_endpoint_url()?;
531 let canonical_url = endpoint.canonical_url(&self.scope, self.url_style);
532 let canonical_host = endpoint.canonical_host(&self.scope, self.url_style);
533
534 let mut headers = self.headers;
535 headers.insert("host".to_string(), canonical_host);
536
537 let header_keys = headers.keys().cloned().collect::<Vec<_>>();
538 let signed_headers = header_keys.join(";");
539
540 let mut query_parameters = self.query_parameters;
541 query_parameters.insert(
542 "X-Goog-Algorithm".to_string(),
543 "GOOG4-RSA-SHA256".to_string(),
544 );
545 query_parameters.insert("X-Goog-Credential".to_string(), credential);
546 query_parameters.insert("X-Goog-Date".to_string(), request_timestamp.clone());
547 query_parameters.insert(
548 "X-Goog-Expires".to_string(),
549 self.expiration.as_secs().to_string(),
550 );
551 query_parameters.insert("X-Goog-SignedHeaders".to_string(), signed_headers.clone());
552
553 let canonical_query = {
554 let mut canonical_query = url::form_urlencoded::Serializer::new("".to_string());
555 for (k, v) in &query_parameters {
556 canonical_query.append_pair(k, v);
557 }
558
559 canonical_query
560 .finish()
561 .replace("%7E", "~") .replace("+", "%20") };
564
565 let canonical_headers = headers.iter().fold("".to_string(), |acc, (k, v)| {
566 let header_value = Self::canonicalize_header_value(v);
567 format!("{acc}{}:{}\n", k, header_value)
568 });
569
570 let signature = headers
573 .get("x-goog-content-sha256")
574 .cloned()
575 .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string());
576
577 let canonical_uri = self.scope.canonical_uri(self.url_style);
578 let canonical_request = [
579 self.method.to_string(),
580 canonical_uri.clone(),
581 canonical_query.clone(),
582 canonical_headers,
583 signed_headers,
584 signature,
585 ]
586 .join("\n");
587
588 let canonical_request_hash = Sha256::digest(canonical_request.as_bytes());
589 let canonical_request_hash = hex::encode(canonical_request_hash);
590
591 let string_to_sign = [
592 "GOOG4-RSA-SHA256".to_string(),
593 request_timestamp,
594 credential_scope,
595 canonical_request_hash,
596 ]
597 .join("\n");
598
599 let signature = signer
600 .sign(string_to_sign.as_str())
601 .await
602 .map_err(SigningError::signing)?;
603
604 let signature = hex::encode(signature);
605
606 let signed_url = format!(
607 "{}?{}&X-Goog-Signature={}",
608 canonical_url, canonical_query, signature
609 );
610
611 Ok(SigningComponents {
612 #[cfg(test)]
613 canonical_request,
614 #[cfg(test)]
615 string_to_sign,
616 signed_url,
617 })
618 }
619
620 pub async fn sign_with(self, signer: &Signer) -> std::result::Result<String, SigningError> {
626 let components = self.sign_internal(signer).await?;
627 Ok(components.signed_url)
628 }
629}
630
631struct SignedUrlEndpoint {
633 scheme: String,
634 host: String,
635 host_with_port: String,
636}
637
638impl SignedUrlEndpoint {
639 fn canonical_url(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
640 scope.canonical_url(&self.scheme, &self.host_with_port, url_style)
641 }
642
643 fn canonical_host(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
644 scope.bucket_host(&self.host, url_style)
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use google_cloud_auth::credentials::service_account::Builder as ServiceAccount;
652 use google_cloud_auth::signer::{Result as SignResult, Signer, SigningError, SigningProvider};
653 use jiff::Timestamp;
654 use pretty_assertions::assert_eq;
655 use serde::Deserialize;
656 use std::collections::HashMap;
657 use tokio::time::Duration;
658
659 type TestResult = anyhow::Result<()>;
660
661 mockall::mock! {
662 #[derive(Debug)]
663 Signer {}
664
665 impl SigningProvider for Signer {
666 async fn client_email(&self) -> SignResult<String>;
667 async fn sign(&self, content: &[u8]) -> SignResult<bytes::Bytes>;
668 }
669 }
670
671 impl SignedUrlBuilder {
672 fn with_timestamp(mut self, timestamp: Timestamp) -> Self {
674 self.timestamp = timestamp;
675 self
676 }
677 }
678
679 #[tokio::test]
680 async fn test_signed_url_builder() -> TestResult {
681 let mut mock = MockSigner::new();
682 mock.expect_client_email()
683 .return_once(|| Ok("test@example.com".to_string()));
684 mock.expect_sign()
685 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
686
687 let signer = Signer::from(mock);
688 let _ = SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", "test-object")
689 .with_method(http::Method::PUT)
690 .with_expiration(Duration::from_secs(3600))
691 .with_header("x-goog-meta-test", "value")
692 .with_query_param("test", "value")
693 .with_endpoint("https://storage.googleapis.com")
694 .with_client_email("test@example.com")
695 .with_url_style(UrlStyle::PathStyle)
696 .sign_with(&signer)
697 .await?;
698
699 Ok(())
700 }
701
702 #[tokio::test]
703 async fn test_signed_url_error_signing() -> TestResult {
704 let mut mock = MockSigner::new();
705 mock.expect_client_email()
706 .return_once(|| Ok("test@example.com".to_string()));
707 mock.expect_sign()
708 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
709
710 let signer = Signer::from(mock);
711 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
712 .sign_with(&signer)
713 .await
714 .unwrap_err();
715
716 assert!(err.is_signing());
717
718 Ok(())
719 }
720
721 #[tokio::test]
722 async fn test_signed_url_error_endpoint() -> TestResult {
723 let mut mock = MockSigner::new();
724 mock.expect_client_email()
725 .return_once(|| Ok("test@example.com".to_string()));
726 mock.expect_sign()
727 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
728
729 let signer = Signer::from(mock);
730 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
731 .with_endpoint("invalid url")
732 .sign_with(&signer)
733 .await
734 .unwrap_err();
735
736 assert!(err.is_invalid_parameter());
737 assert!(err.to_string().contains("invalid `endpoint` parameter"));
738
739 Ok(())
740 }
741
742 #[tokio::test]
743 async fn test_signed_url_error_bucket() -> TestResult {
744 let mut mock = MockSigner::new();
745 mock.expect_client_email()
746 .return_once(|| Ok("test@example.com".to_string()));
747 mock.expect_sign()
748 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
749
750 let signer = Signer::from(mock);
751 let err = SignedUrlBuilder::for_object("invalid-bucket-name", "o")
752 .sign_with(&signer)
753 .await
754 .unwrap_err();
755
756 assert!(err.is_invalid_parameter());
757 assert!(err.to_string().contains("malformed bucket name"));
758
759 Ok(())
760 }
761
762 #[tokio::test]
763 async fn sign_with_is_send() -> TestResult {
764 fn assert_send<T: Send>(_t: &T) {}
765
766 let mut mock = MockSigner::new();
767 mock.expect_client_email()
768 .return_once(|| Ok("test@example.com".to_string()));
769 mock.expect_sign()
770 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
771
772 let signer = Signer::from(mock);
773 let fut = SignedUrlBuilder::for_object("projects/_/buckets/b", "o").sign_with(&signer);
774
775 assert_send(&fut);
776
777 Ok(())
778 }
779
780 #[test_case::test_case(
781 Some("path/with/slashes/under_score/amper&sand/file.ext"),
782 None,
783 UrlStyle::PathStyle,
784 "https://storage.googleapis.com/test-bucket/path/with/slashes/under_score/amper%26sand/file.ext"
785 ; "escape object name")]
786 #[test_case::test_case(
787 Some("folder/test object.txt"),
788 None,
789 UrlStyle::PathStyle,
790 "https://storage.googleapis.com/test-bucket/folder/test%20object.txt"
791 ; "escape object name with spaces")]
792 #[test_case::test_case(
793 Some("test-object"),
794 None,
795 UrlStyle::VirtualHostedStyle,
796 "https://test-bucket.storage.googleapis.com/test-object"
797 ; "virtual hosted style")]
798 #[test_case::test_case(
799 Some("test-object"),
800 Some("http://mydomain.tld"),
801 UrlStyle::BucketBoundHostname,
802 "http://mydomain.tld/test-object"
803 ; "bucket bound style")]
804 #[test_case::test_case(
805 None,
806 None,
807 UrlStyle::PathStyle,
808 "https://storage.googleapis.com/test-bucket"
809 ; "list objects")]
810 #[test_case::test_case(
811 Some(r#"folder/"<>\^`{|}"#),
812 None,
813 UrlStyle::PathStyle,
814 "https://storage.googleapis.com/test-bucket/folder/%22%3C%3E%5C%5E%60%7B%7C%7D"
815 ; "escape special ascii")]
816 #[test_case::test_case(
817 Some(r"tenant-a/uploads\..\..\tenant-b/secret"),
818 None,
819 UrlStyle::PathStyle,
820 "https://storage.googleapis.com/test-bucket/tenant-a/uploads%5C..%5C..%5Ctenant-b/secret"
821 ; "escape backslash")]
822 #[test_case::test_case(
823 Some("folder/100%.txt"),
824 None,
825 UrlStyle::VirtualHostedStyle,
826 "https://test-bucket.storage.googleapis.com/folder/100%25.txt"
827 ; "escape percent")]
828 #[test_case::test_case(
829 Some("folder/AZaz09-._~"),
830 None,
831 UrlStyle::PathStyle,
832 "https://storage.googleapis.com/test-bucket/folder/AZaz09-._~"
833 ; "keep unreserved")]
834 fn test_signed_url_canonical_url(
835 object: Option<&str>,
836 endpoint: Option<&str>,
837 url_style: UrlStyle,
838 expected_url: &str,
839 ) -> TestResult {
840 let builder = if let Some(object) = object {
842 SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", object)
843 } else {
844 SignedUrlBuilder::for_bucket("projects/_/buckets/test-bucket")
845 };
846 let builder = builder.with_url_style(url_style);
847 let builder = endpoint.iter().fold(builder, |builder, endpoint| {
848 builder.with_endpoint(*endpoint)
849 });
850
851 let endpoint = builder.resolve_endpoint_url()?;
852 let url = endpoint.canonical_url(&builder.scope, builder.url_style);
854 assert_eq!(url, expected_url);
856
857 Ok(())
858 }
859
860 #[test_case::test_case(r#"folder/"<>\^`{|}"#; "special ascii")]
864 #[test_case::test_case(r"tenant-a/uploads\..\..\tenant-b/secret"; "backslash dot segments")]
865 #[test_case::test_case("tenant-a/uploads/.%2e/%2e%2e/tenant-b/secret"; "percent encoded dot segments")]
866 fn signed_url_path_survives_url_parsing(object: &str) -> TestResult {
867 let builder = SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", object);
869 let endpoint = builder.resolve_endpoint_url()?;
870 let canonical_url = endpoint.canonical_url(&builder.scope, builder.url_style);
872 let canonical_uri = builder.scope.canonical_uri(builder.url_style);
873 let parsed = url::Url::parse(&canonical_url)?;
875 assert_eq!(parsed.path(), canonical_uri);
876 Ok(())
877 }
878
879 #[derive(Deserialize)]
880 #[serde(rename_all = "camelCase")]
881 struct SignedUrlTestSuite {
882 signing_v4_tests: Vec<SignedUrlTest>,
883 }
884
885 #[derive(Deserialize)]
886 #[serde(rename_all = "camelCase")]
887 struct SignedUrlTest {
888 description: String,
889 bucket: String,
890 object: Option<String>,
891 method: String,
892 expiration: u64,
893 timestamp: String,
894 expected_url: String,
895 headers: Option<HashMap<String, String>>,
896 query_parameters: Option<HashMap<String, String>>,
897 scheme: Option<String>,
898 url_style: Option<String>,
899 bucket_bound_hostname: Option<String>,
900 expected_canonical_request: String,
901 expected_string_to_sign: String,
902 hostname: Option<String>,
903 client_endpoint: Option<String>,
904 emulator_hostname: Option<String>,
905 universe_domain: Option<String>,
906 }
907
908 #[tokio::test]
909 async fn signed_url_conformance() -> anyhow::Result<()> {
910 let service_account_key = serde_json::from_slice(include_bytes!(
911 "conformance/test_service_account.not-a-test.json"
912 ))?;
913
914 let signer = ServiceAccount::new(service_account_key)
915 .build_signer()
916 .expect("failed to build signer");
917
918 let suite: SignedUrlTestSuite =
919 serde_json::from_slice(include_bytes!("conformance/v4_signatures.json"))?;
920
921 let mut failed_tests = Vec::new();
922 let mut skipped_tests = Vec::new();
923 let mut passed_tests = Vec::new();
924 let total_tests = suite.signing_v4_tests.len();
925 for test in suite.signing_v4_tests {
926 let timestamp: Timestamp = test.timestamp.parse().expect("invalid timestamp");
927 let method = http::Method::from_bytes(test.method.as_bytes()).expect("invalid method");
928 let scheme = test.scheme.unwrap_or("https".to_string());
929 let url_style = match test.url_style {
930 Some(url_style) => match url_style.as_str() {
931 "VIRTUAL_HOSTED_STYLE" => UrlStyle::VirtualHostedStyle,
932 "BUCKET_BOUND_HOSTNAME" => UrlStyle::BucketBoundHostname,
933 _ => UrlStyle::PathStyle,
934 },
935 None => UrlStyle::PathStyle,
936 };
937
938 let bucket = format!("projects/_/buckets/{}", test.bucket);
939 let builder = match test.object {
940 Some(object) => SignedUrlBuilder::for_object(bucket, object),
941 None => SignedUrlBuilder::for_bucket(bucket),
942 };
943
944 if test.emulator_hostname.is_some() {
945 skipped_tests.push(test.description);
946 continue;
947 }
948
949 let builder = builder
950 .with_method(method)
951 .with_url_style(url_style)
952 .with_expiration(Duration::from_secs(test.expiration))
953 .with_timestamp(timestamp);
954
955 let builder = test
956 .universe_domain
957 .iter()
958 .fold(builder, |builder, universe_domain| {
959 builder.with_universe_domain(universe_domain)
960 });
961 let builder = test
962 .client_endpoint
963 .iter()
964 .fold(builder, |builder, client_endpoint| {
965 builder.with_endpoint(client_endpoint)
966 });
967 let builder = test
968 .bucket_bound_hostname
969 .iter()
970 .fold(builder, |builder, hostname| {
971 builder.with_endpoint(format!("{}://{}", scheme, hostname))
972 });
973 let builder = test.hostname.iter().fold(builder, |builder, hostname| {
974 builder.with_endpoint(format!("{}://{}", scheme, hostname))
975 });
976 let builder = test.headers.iter().fold(builder, |builder, headers| {
977 headers.iter().fold(builder, |builder, (k, v)| {
978 builder.with_header(k.clone(), v.clone())
979 })
980 });
981 let builder = test
982 .query_parameters
983 .iter()
984 .fold(builder, |builder, query_params| {
985 query_params.iter().fold(builder, |builder, (k, v)| {
986 builder.with_query_param(k.clone(), v.clone())
987 })
988 });
989
990 let components = builder.sign_internal(&signer).await;
991 let components = match components {
992 Ok(components) => components,
993 Err(e) => {
994 println!("❌ Failed test: {}", test.description);
995 println!("Error: {}", e);
996 failed_tests.push(test.description);
997 continue;
998 }
999 };
1000
1001 let canonical_request = components.canonical_request;
1002 let string_to_sign = components.string_to_sign;
1003 let signed_url = components.signed_url;
1004
1005 if canonical_request != test.expected_canonical_request
1006 || string_to_sign != test.expected_string_to_sign
1007 || signed_url != test.expected_url
1008 {
1009 println!("❌ Failed test: {}", test.description);
1010 let diff = pretty_assertions::StrComparison::new(
1011 &canonical_request,
1012 &test.expected_canonical_request,
1013 );
1014 println!("Canonical request diff: {}", diff);
1015 let diff = pretty_assertions::StrComparison::new(
1016 &string_to_sign,
1017 &test.expected_string_to_sign,
1018 );
1019 println!("String to sign diff: {}", diff);
1020 let diff = pretty_assertions::StrComparison::new(&signed_url, &test.expected_url);
1021 println!("Signed URL diff: {}", diff);
1022 failed_tests.push(test.description);
1023 continue;
1024 }
1025 passed_tests.push(test.description);
1026 }
1027
1028 let failed = !failed_tests.is_empty();
1029 let total_passed = passed_tests.len();
1030 for test in passed_tests {
1031 println!("✅ Passed test: {}", test);
1032 }
1033 for test in skipped_tests {
1034 println!("🟡 Skipped test: {}", test);
1035 }
1036 for test in failed_tests {
1037 println!("❌ Failed test: {}", test);
1038 }
1039 println!("{}/{} tests passed", total_passed, total_tests);
1040
1041 if failed {
1042 anyhow::bail!("Some tests failed")
1043 }
1044 Ok(())
1045 }
1046}