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