1use crate::{error::SigningError, signed_url::UrlStyle, storage::client::ENCODED_CHARS};
16use chrono::{DateTime, Utc};
17use google_cloud_auth::signer::Signer;
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'/');
25
26#[derive(Debug)]
121pub struct SignedUrlBuilder {
122 scope: SigningScope,
123 method: http::Method,
124 expiration: std::time::Duration,
125 headers: BTreeMap<String, String>,
126 query_parameters: BTreeMap<String, String>,
127 endpoint: Option<String>,
128 universe_domain: Option<String>,
129 client_email: Option<String>,
130 timestamp: DateTime<Utc>,
131 url_style: UrlStyle,
132}
133
134#[derive(Debug)]
135enum SigningScope {
136 Bucket(String),
137 Object(String, String),
138}
139
140impl SigningScope {
141 fn check_bucket_name(&self) -> Result<(), SigningError> {
142 let bucket = match self {
143 SigningScope::Bucket(bucket) => bucket,
144 SigningScope::Object(bucket, _) => bucket,
145 };
146
147 bucket.strip_prefix("projects/_/buckets/").ok_or_else(|| {
148 SigningError::invalid_parameter(
149 "bucket",
150 format!(
151 "malformed bucket name, it must start with `projects/_/buckets/`: {bucket}"
152 ),
153 )
154 })?;
155
156 Ok(())
157 }
158
159 fn bucket_name(&self) -> String {
160 let bucket = match self {
161 SigningScope::Bucket(bucket) => bucket,
162 SigningScope::Object(bucket, _) => bucket,
163 };
164
165 bucket.trim_start_matches("projects/_/buckets/").to_string()
166 }
167
168 fn bucket_host(&self, host: &str, url_style: UrlStyle) -> String {
169 match url_style {
170 UrlStyle::PathStyle => host.to_string(),
171 UrlStyle::BucketBoundHostname => host.to_string(),
172 UrlStyle::VirtualHostedStyle => format!("{}.{host}", self.bucket_name()),
173 }
174 }
175
176 fn bucket_endpoint(&self, scheme: &str, host: &str, url_style: UrlStyle) -> String {
177 let bucket_host = self.bucket_host(host, url_style);
178 format!("{scheme}://{bucket_host}")
179 }
180
181 fn canonical_uri(&self, url_style: UrlStyle) -> String {
182 let bucket_name = self.bucket_name();
183 match self {
184 SigningScope::Object(_, object) => {
185 let encoded_object = utf8_percent_encode(object, &PATH_ENCODE_SET);
186 match url_style {
187 UrlStyle::PathStyle => {
188 format!("/{bucket_name}/{encoded_object}")
189 }
190 UrlStyle::BucketBoundHostname => {
191 format!("/{encoded_object}")
192 }
193 UrlStyle::VirtualHostedStyle => {
194 format!("/{encoded_object}")
195 }
196 }
197 }
198 SigningScope::Bucket(_) => match url_style {
199 UrlStyle::PathStyle => {
200 format!("/{bucket_name}")
201 }
202 UrlStyle::BucketBoundHostname => "".to_string(),
203 UrlStyle::VirtualHostedStyle => "".to_string(),
204 },
205 }
206 }
207
208 fn canonical_url(&self, scheme: &str, host: &str, url_style: UrlStyle) -> String {
209 let bucket_endpoint = self.bucket_endpoint(scheme, host, url_style);
210 let uri = self.canonical_uri(url_style);
211 format!("{bucket_endpoint}{uri}")
212 }
213}
214
215struct SigningComponents {
217 #[cfg(test)]
218 canonical_request: String,
219 #[cfg(test)]
220 string_to_sign: String,
221 signed_url: String,
222}
223
224impl SignedUrlBuilder {
225 fn new(scope: SigningScope) -> Self {
226 Self {
227 scope,
228 method: http::Method::GET,
229 expiration: std::time::Duration::from_secs(7 * 24 * 60 * 60), headers: BTreeMap::new(),
231 query_parameters: BTreeMap::new(),
232 endpoint: None,
233 universe_domain: None,
234 client_email: None,
235 timestamp: Utc::now(),
236 url_style: UrlStyle::PathStyle,
237 }
238 }
239
240 pub fn for_object<B, O>(bucket: B, object: O) -> Self
255 where
256 B: Into<String>,
257 O: Into<String>,
258 {
259 Self::new(SigningScope::Object(bucket.into(), object.into()))
260 }
261
262 pub fn for_bucket<B>(bucket: B) -> Self
277 where
278 B: Into<String>,
279 {
280 Self::new(SigningScope::Bucket(bucket.into()))
281 }
282
283 pub fn with_method(mut self, method: http::Method) -> Self {
301 self.method = method;
302 self
303 }
304
305 pub fn with_expiration(mut self, expiration: std::time::Duration) -> Self {
325 self.expiration = expiration;
326 self
327 }
328
329 pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
347 self.url_style = url_style;
348 self
349 }
350
351 pub fn with_header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
372 self.headers.insert(key.into().to_lowercase(), value.into());
373 self
374 }
375
376 pub fn with_query_param<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
393 self.query_parameters.insert(key.into(), value.into());
394 self
395 }
396
397 pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
415 self.endpoint = Some(endpoint.into());
416 self
417 }
418
419 pub fn with_universe_domain<V: Into<String>>(mut self, v: V) -> Self {
438 self.universe_domain = Some(v.into());
439 self
440 }
441
442 pub fn with_client_email<S: Into<String>>(mut self, client_email: S) -> Self {
460 self.client_email = Some(client_email.into());
461 self
462 }
463
464 fn resolve_endpoint_url(&self) -> Result<SignedUrlEndpoint, SigningError> {
465 let endpoint = self.resolve_endpoint();
466 let url = url::Url::parse(&endpoint)
467 .map_err(|e| SigningError::invalid_parameter("endpoint", e))?;
468 let host = url.host_str().ok_or_else(|| {
469 SigningError::invalid_parameter("endpoint", "Invalid endpoint, missing host.")
470 })?;
471
472 let path = url.path();
476 let scheme = format!("{}://", url.scheme());
477 let host_with_port = endpoint.trim_start_matches(&scheme).trim_end_matches(path);
478
479 Ok(SignedUrlEndpoint {
480 scheme: url.scheme().to_string(),
481 host_with_port: host_with_port.to_string(),
482 host: host.to_string(),
483 })
484 }
485
486 fn resolve_endpoint(&self) -> String {
487 match self.endpoint.as_ref() {
488 Some(e) if e.starts_with("http://") => e.clone(),
489 Some(e) if e.starts_with("https://") => e.clone(),
490 Some(e) => format!("https://{}", e),
491 None => {
492 let universe_domain = self.universe_domain.as_deref().unwrap_or("googleapis.com");
493 format!("https://storage.{universe_domain}")
494 }
495 }
496 }
497
498 fn canonicalize_header_value(value: &str) -> String {
499 let clean_value = value.replace("\t", " ").trim().to_string();
500 clean_value.split_whitespace().collect::<Vec<_>>().join(" ")
501 }
502
503 async fn sign_internal(
507 self,
508 signer: &Signer,
509 ) -> std::result::Result<SigningComponents, SigningError> {
510 self.scope.check_bucket_name()?;
512
513 let now = self.timestamp;
514 let request_timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
515 let datestamp = now.format("%Y%m%d");
516 let credential_scope = format!("{datestamp}/auto/storage/goog4_request");
517 let client_email = if let Some(email) = self.client_email.clone() {
518 email
519 } else {
520 signer.client_email().await.map_err(SigningError::signing)?
521 };
522 let credential = format!("{client_email}/{credential_scope}");
523
524 let endpoint = self.resolve_endpoint_url()?;
525 let canonical_url = endpoint.canonical_url(&self.scope, self.url_style);
526 let canonical_host = endpoint.canonical_host(&self.scope, self.url_style);
527
528 let mut headers = self.headers;
529 headers.insert("host".to_string(), canonical_host);
530
531 let header_keys = headers.keys().cloned().collect::<Vec<_>>();
532 let signed_headers = header_keys.join(";");
533
534 let mut query_parameters = self.query_parameters;
535 query_parameters.insert(
536 "X-Goog-Algorithm".to_string(),
537 "GOOG4-RSA-SHA256".to_string(),
538 );
539 query_parameters.insert("X-Goog-Credential".to_string(), credential);
540 query_parameters.insert("X-Goog-Date".to_string(), request_timestamp.clone());
541 query_parameters.insert(
542 "X-Goog-Expires".to_string(),
543 self.expiration.as_secs().to_string(),
544 );
545 query_parameters.insert("X-Goog-SignedHeaders".to_string(), signed_headers.clone());
546
547 let canonical_query = {
548 let mut canonical_query = url::form_urlencoded::Serializer::new("".to_string());
549 for (k, v) in &query_parameters {
550 canonical_query.append_pair(k, v);
551 }
552
553 canonical_query
554 .finish()
555 .replace("%7E", "~") .replace("+", "%20") };
558
559 let canonical_headers = headers.iter().fold("".to_string(), |acc, (k, v)| {
560 let header_value = Self::canonicalize_header_value(v);
561 format!("{acc}{}:{}\n", k, header_value)
562 });
563
564 let signature = headers
567 .get("x-goog-content-sha256")
568 .cloned()
569 .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string());
570
571 let canonical_uri = self.scope.canonical_uri(self.url_style);
572 let canonical_request = [
573 self.method.to_string(),
574 canonical_uri.clone(),
575 canonical_query.clone(),
576 canonical_headers,
577 signed_headers,
578 signature,
579 ]
580 .join("\n");
581
582 let canonical_request_hash = Sha256::digest(canonical_request.as_bytes());
583 let canonical_request_hash = hex::encode(canonical_request_hash);
584
585 let string_to_sign = [
586 "GOOG4-RSA-SHA256".to_string(),
587 request_timestamp,
588 credential_scope,
589 canonical_request_hash,
590 ]
591 .join("\n");
592
593 let signature = signer
594 .sign(string_to_sign.as_str())
595 .await
596 .map_err(SigningError::signing)?;
597
598 let signature = hex::encode(signature);
599
600 let signed_url = format!(
601 "{}?{}&X-Goog-Signature={}",
602 canonical_url, canonical_query, signature
603 );
604
605 Ok(SigningComponents {
606 #[cfg(test)]
607 canonical_request,
608 #[cfg(test)]
609 string_to_sign,
610 signed_url,
611 })
612 }
613
614 pub async fn sign_with(self, signer: &Signer) -> std::result::Result<String, SigningError> {
620 let components = self.sign_internal(signer).await?;
621 Ok(components.signed_url)
622 }
623}
624
625struct SignedUrlEndpoint {
627 scheme: String,
628 host: String,
629 host_with_port: String,
630}
631
632impl SignedUrlEndpoint {
633 fn canonical_url(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
634 scope.canonical_url(&self.scheme, &self.host_with_port, url_style)
635 }
636
637 fn canonical_host(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
638 scope.bucket_host(&self.host, url_style)
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use chrono::DateTime;
646 use google_cloud_auth::credentials::service_account::Builder as ServiceAccount;
647 use google_cloud_auth::signer::{Result as SignResult, Signer, SigningError, SigningProvider};
648 use serde::Deserialize;
649 use std::collections::HashMap;
650 use tokio::time::Duration;
651
652 type TestResult = anyhow::Result<()>;
653
654 mockall::mock! {
655 #[derive(Debug)]
656 Signer {}
657
658 impl SigningProvider for Signer {
659 async fn client_email(&self) -> SignResult<String>;
660 async fn sign(&self, content: &[u8]) -> SignResult<bytes::Bytes>;
661 }
662 }
663
664 impl SignedUrlBuilder {
665 fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
667 self.timestamp = timestamp;
668 self
669 }
670 }
671
672 #[tokio::test]
673 async fn test_signed_url_builder() -> TestResult {
674 let mut mock = MockSigner::new();
675 mock.expect_client_email()
676 .return_once(|| Ok("test@example.com".to_string()));
677 mock.expect_sign()
678 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
679
680 let signer = Signer::from(mock);
681 let _ = SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", "test-object")
682 .with_method(http::Method::PUT)
683 .with_expiration(Duration::from_secs(3600))
684 .with_header("x-goog-meta-test", "value")
685 .with_query_param("test", "value")
686 .with_endpoint("https://storage.googleapis.com")
687 .with_client_email("test@example.com")
688 .with_url_style(UrlStyle::PathStyle)
689 .sign_with(&signer)
690 .await?;
691
692 Ok(())
693 }
694
695 #[tokio::test]
696 async fn test_signed_url_error_signing() -> TestResult {
697 let mut mock = MockSigner::new();
698 mock.expect_client_email()
699 .return_once(|| Ok("test@example.com".to_string()));
700 mock.expect_sign()
701 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
702
703 let signer = Signer::from(mock);
704 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
705 .sign_with(&signer)
706 .await
707 .unwrap_err();
708
709 assert!(err.is_signing());
710
711 Ok(())
712 }
713
714 #[tokio::test]
715 async fn test_signed_url_error_endpoint() -> TestResult {
716 let mut mock = MockSigner::new();
717 mock.expect_client_email()
718 .return_once(|| Ok("test@example.com".to_string()));
719 mock.expect_sign()
720 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
721
722 let signer = Signer::from(mock);
723 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
724 .with_endpoint("invalid url")
725 .sign_with(&signer)
726 .await
727 .unwrap_err();
728
729 assert!(err.is_invalid_parameter());
730 assert!(err.to_string().contains("invalid `endpoint` parameter"));
731
732 Ok(())
733 }
734
735 #[tokio::test]
736 async fn test_signed_url_error_bucket() -> TestResult {
737 let mut mock = MockSigner::new();
738 mock.expect_client_email()
739 .return_once(|| Ok("test@example.com".to_string()));
740 mock.expect_sign()
741 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
742
743 let signer = Signer::from(mock);
744 let err = SignedUrlBuilder::for_object("invalid-bucket-name", "o")
745 .sign_with(&signer)
746 .await
747 .unwrap_err();
748
749 assert!(err.is_invalid_parameter());
750 assert!(err.to_string().contains("malformed bucket name"));
751
752 Ok(())
753 }
754
755 #[tokio::test]
756 async fn sign_with_is_send() -> TestResult {
757 fn assert_send<T: Send>(_t: &T) {}
758
759 let mut mock = MockSigner::new();
760 mock.expect_client_email()
761 .return_once(|| Ok("test@example.com".to_string()));
762 mock.expect_sign()
763 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
764
765 let signer = Signer::from(mock);
766 let fut = SignedUrlBuilder::for_object("projects/_/buckets/b", "o").sign_with(&signer);
767
768 assert_send(&fut);
769
770 Ok(())
771 }
772
773 #[test_case::test_case(
774 Some("path/with/slashes/under_score/amper&sand/file.ext"),
775 None,
776 UrlStyle::PathStyle,
777 "https://storage.googleapis.com/test-bucket/path/with/slashes/under_score/amper%26sand/file.ext"
778 ; "escape object name")]
779 #[test_case::test_case(
780 Some("folder/test object.txt"),
781 None,
782 UrlStyle::PathStyle,
783 "https://storage.googleapis.com/test-bucket/folder/test%20object.txt"
784 ; "escape object name with spaces")]
785 #[test_case::test_case(
786 Some("test-object"),
787 None,
788 UrlStyle::VirtualHostedStyle,
789 "https://test-bucket.storage.googleapis.com/test-object"
790 ; "virtual hosted style")]
791 #[test_case::test_case(
792 Some("test-object"),
793 Some("http://mydomain.tld"),
794 UrlStyle::BucketBoundHostname,
795 "http://mydomain.tld/test-object"
796 ; "bucket bound style")]
797 #[test_case::test_case(
798 None,
799 None,
800 UrlStyle::PathStyle,
801 "https://storage.googleapis.com/test-bucket"
802 ; "list objects")]
803 fn test_signed_url_canonical_url(
804 object: Option<&str>,
805 endpoint: Option<&str>,
806 url_style: UrlStyle,
807 expected_url: &str,
808 ) -> TestResult {
809 let builder = if let Some(object) = object {
810 SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", object)
811 } else {
812 SignedUrlBuilder::for_bucket("projects/_/buckets/test-bucket")
813 };
814 let builder = builder.with_url_style(url_style);
815 let builder = endpoint.iter().fold(builder, |builder, endpoint| {
816 builder.with_endpoint(*endpoint)
817 });
818
819 let endpoint = builder.resolve_endpoint_url()?;
820 let url = endpoint.canonical_url(&builder.scope, builder.url_style);
821 assert_eq!(url, expected_url);
822
823 Ok(())
824 }
825
826 #[derive(Deserialize)]
827 #[serde(rename_all = "camelCase")]
828 struct SignedUrlTestSuite {
829 signing_v4_tests: Vec<SignedUrlTest>,
830 }
831
832 #[derive(Deserialize)]
833 #[serde(rename_all = "camelCase")]
834 struct SignedUrlTest {
835 description: String,
836 bucket: String,
837 object: Option<String>,
838 method: String,
839 expiration: u64,
840 timestamp: String,
841 expected_url: String,
842 headers: Option<HashMap<String, String>>,
843 query_parameters: Option<HashMap<String, String>>,
844 scheme: Option<String>,
845 url_style: Option<String>,
846 bucket_bound_hostname: Option<String>,
847 expected_canonical_request: String,
848 expected_string_to_sign: String,
849 hostname: Option<String>,
850 client_endpoint: Option<String>,
851 emulator_hostname: Option<String>,
852 universe_domain: Option<String>,
853 }
854
855 #[tokio::test]
856 async fn signed_url_conformance() -> anyhow::Result<()> {
857 let service_account_key = serde_json::from_slice(include_bytes!(
858 "conformance/test_service_account.not-a-test.json"
859 ))?;
860
861 let signer = ServiceAccount::new(service_account_key)
862 .build_signer()
863 .expect("failed to build signer");
864
865 let suite: SignedUrlTestSuite =
866 serde_json::from_slice(include_bytes!("conformance/v4_signatures.json"))?;
867
868 let mut failed_tests = Vec::new();
869 let mut skipped_tests = Vec::new();
870 let mut passed_tests = Vec::new();
871 let total_tests = suite.signing_v4_tests.len();
872 for test in suite.signing_v4_tests {
873 let timestamp =
874 DateTime::parse_from_rfc3339(&test.timestamp).expect("invalid timestamp");
875 let method = http::Method::from_bytes(test.method.as_bytes()).expect("invalid method");
876 let scheme = test.scheme.unwrap_or("https".to_string());
877 let url_style = match test.url_style {
878 Some(url_style) => match url_style.as_str() {
879 "VIRTUAL_HOSTED_STYLE" => UrlStyle::VirtualHostedStyle,
880 "BUCKET_BOUND_HOSTNAME" => UrlStyle::BucketBoundHostname,
881 _ => UrlStyle::PathStyle,
882 },
883 None => UrlStyle::PathStyle,
884 };
885
886 let bucket = format!("projects/_/buckets/{}", test.bucket);
887 let builder = match test.object {
888 Some(object) => SignedUrlBuilder::for_object(bucket, object),
889 None => SignedUrlBuilder::for_bucket(bucket),
890 };
891
892 if test.emulator_hostname.is_some() {
893 skipped_tests.push(test.description);
894 continue;
895 }
896
897 let builder = builder
898 .with_method(method)
899 .with_url_style(url_style)
900 .with_expiration(Duration::from_secs(test.expiration))
901 .with_timestamp(timestamp.into());
902
903 let builder = test
904 .universe_domain
905 .iter()
906 .fold(builder, |builder, universe_domain| {
907 builder.with_universe_domain(universe_domain)
908 });
909 let builder = test
910 .client_endpoint
911 .iter()
912 .fold(builder, |builder, client_endpoint| {
913 builder.with_endpoint(client_endpoint)
914 });
915 let builder = test
916 .bucket_bound_hostname
917 .iter()
918 .fold(builder, |builder, hostname| {
919 builder.with_endpoint(format!("{}://{}", scheme, hostname))
920 });
921 let builder = test.hostname.iter().fold(builder, |builder, hostname| {
922 builder.with_endpoint(format!("{}://{}", scheme, hostname))
923 });
924 let builder = test.headers.iter().fold(builder, |builder, headers| {
925 headers.iter().fold(builder, |builder, (k, v)| {
926 builder.with_header(k.clone(), v.clone())
927 })
928 });
929 let builder = test
930 .query_parameters
931 .iter()
932 .fold(builder, |builder, query_params| {
933 query_params.iter().fold(builder, |builder, (k, v)| {
934 builder.with_query_param(k.clone(), v.clone())
935 })
936 });
937
938 let components = builder.sign_internal(&signer).await;
939 let components = match components {
940 Ok(components) => components,
941 Err(e) => {
942 println!("❌ Failed test: {}", test.description);
943 println!("Error: {}", e);
944 failed_tests.push(test.description);
945 continue;
946 }
947 };
948
949 let canonical_request = components.canonical_request;
950 let string_to_sign = components.string_to_sign;
951 let signed_url = components.signed_url;
952
953 if canonical_request != test.expected_canonical_request
954 || string_to_sign != test.expected_string_to_sign
955 || signed_url != test.expected_url
956 {
957 println!("❌ Failed test: {}", test.description);
958 let diff = pretty_assertions::StrComparison::new(
959 &canonical_request,
960 &test.expected_canonical_request,
961 );
962 println!("Canonical request diff: {}", diff);
963 let diff = pretty_assertions::StrComparison::new(
964 &string_to_sign,
965 &test.expected_string_to_sign,
966 );
967 println!("String to sign diff: {}", diff);
968 let diff = pretty_assertions::StrComparison::new(&signed_url, &test.expected_url);
969 println!("Signed URL diff: {}", diff);
970 failed_tests.push(test.description);
971 continue;
972 }
973 passed_tests.push(test.description);
974 }
975
976 let failed = !failed_tests.is_empty();
977 let total_passed = passed_tests.len();
978 for test in passed_tests {
979 println!("✅ Passed test: {}", test);
980 }
981 for test in skipped_tests {
982 println!("🟡 Skipped test: {}", test);
983 }
984 for test in failed_tests {
985 println!("❌ Failed test: {}", test);
986 }
987 println!("{}/{} tests passed", total_passed, total_tests);
988
989 if failed {
990 anyhow::bail!("Some tests failed")
991 }
992 Ok(())
993 }
994}