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 pub fn with_method(mut self, method: http::Method) -> Self {
299 self.method = method;
300 self
301 }
302
303 pub fn with_expiration(mut self, expiration: std::time::Duration) -> Self {
323 self.expiration = expiration;
324 self
325 }
326
327 pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
345 self.url_style = url_style;
346 self
347 }
348
349 pub fn with_header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
370 self.headers.insert(key.into().to_lowercase(), value.into());
371 self
372 }
373
374 pub fn with_query_param<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
391 self.query_parameters.insert(key.into(), value.into());
392 self
393 }
394
395 pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
413 self.endpoint = Some(endpoint.into());
414 self
415 }
416
417 pub fn with_client_email<S: Into<String>>(mut self, client_email: S) -> Self {
435 self.client_email = Some(client_email.into());
436 self
437 }
438
439 fn resolve_endpoint_url(&self) -> Result<SignedUrlEndpoint, SigningError> {
440 let endpoint = self.resolve_endpoint();
441 let url = url::Url::parse(&endpoint)
442 .map_err(|e| SigningError::invalid_parameter("endpoint", e))?;
443 let host = url.host_str().ok_or_else(|| {
444 SigningError::invalid_parameter("endpoint", "Invalid endpoint, missing host.")
445 })?;
446
447 let path = url.path();
451 let scheme = format!("{}://", url.scheme());
452 let host_with_port = endpoint.trim_start_matches(&scheme).trim_end_matches(path);
453
454 Ok(SignedUrlEndpoint {
455 scheme: url.scheme().to_string(),
456 host_with_port: host_with_port.to_string(),
457 host: host.to_string(),
458 })
459 }
460
461 fn resolve_endpoint(&self) -> String {
462 match self.endpoint.as_ref() {
463 Some(e) if e.starts_with("http://") => e.clone(),
464 Some(e) if e.starts_with("https://") => e.clone(),
465 Some(e) => format!("https://{}", e),
466 None => "https://storage.googleapis.com".to_string(),
467 }
468 }
469
470 fn canonicalize_header_value(value: &str) -> String {
471 let clean_value = value.replace("\t", " ").trim().to_string();
472 clean_value.split_whitespace().collect::<Vec<_>>().join(" ")
473 }
474
475 async fn sign_internal(
479 self,
480 signer: &Signer,
481 ) -> std::result::Result<SigningComponents, SigningError> {
482 self.scope.check_bucket_name()?;
484
485 let now = self.timestamp;
486 let request_timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
487 let datestamp = now.format("%Y%m%d");
488 let credential_scope = format!("{datestamp}/auto/storage/goog4_request");
489 let client_email = if let Some(email) = self.client_email.clone() {
490 email
491 } else {
492 signer.client_email().await.map_err(SigningError::signing)?
493 };
494 let credential = format!("{client_email}/{credential_scope}");
495
496 let endpoint = self.resolve_endpoint_url()?;
497 let canonical_url = endpoint.canonical_url(&self.scope, self.url_style);
498 let canonical_host = endpoint.canonical_host(&self.scope, self.url_style);
499
500 let mut headers = self.headers;
501 headers.insert("host".to_string(), canonical_host);
502
503 let header_keys = headers.keys().cloned().collect::<Vec<_>>();
504 let signed_headers = header_keys.join(";");
505
506 let mut query_parameters = self.query_parameters;
507 query_parameters.insert(
508 "X-Goog-Algorithm".to_string(),
509 "GOOG4-RSA-SHA256".to_string(),
510 );
511 query_parameters.insert("X-Goog-Credential".to_string(), credential);
512 query_parameters.insert("X-Goog-Date".to_string(), request_timestamp.clone());
513 query_parameters.insert(
514 "X-Goog-Expires".to_string(),
515 self.expiration.as_secs().to_string(),
516 );
517 query_parameters.insert("X-Goog-SignedHeaders".to_string(), signed_headers.clone());
518
519 let canonical_query = {
520 let mut canonical_query = url::form_urlencoded::Serializer::new("".to_string());
521 for (k, v) in &query_parameters {
522 canonical_query.append_pair(k, v);
523 }
524
525 canonical_query
526 .finish()
527 .replace("%7E", "~") .replace("+", "%20") };
530
531 let canonical_headers = headers.iter().fold("".to_string(), |acc, (k, v)| {
532 let header_value = Self::canonicalize_header_value(v);
533 format!("{acc}{}:{}\n", k, header_value)
534 });
535
536 let signature = headers
539 .get("x-goog-content-sha256")
540 .cloned()
541 .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string());
542
543 let canonical_uri = self.scope.canonical_uri(self.url_style);
544 let canonical_request = [
545 self.method.to_string(),
546 canonical_uri.clone(),
547 canonical_query.clone(),
548 canonical_headers,
549 signed_headers,
550 signature,
551 ]
552 .join("\n");
553
554 let canonical_request_hash = Sha256::digest(canonical_request.as_bytes());
555 let canonical_request_hash = hex::encode(canonical_request_hash);
556
557 let string_to_sign = [
558 "GOOG4-RSA-SHA256".to_string(),
559 request_timestamp,
560 credential_scope,
561 canonical_request_hash,
562 ]
563 .join("\n");
564
565 let signature = signer
566 .sign(string_to_sign.as_str())
567 .await
568 .map_err(SigningError::signing)?;
569
570 let signature = hex::encode(signature);
571
572 let signed_url = format!(
573 "{}?{}&X-Goog-Signature={}",
574 canonical_url, canonical_query, signature
575 );
576
577 Ok(SigningComponents {
578 #[cfg(test)]
579 canonical_request,
580 #[cfg(test)]
581 string_to_sign,
582 signed_url,
583 })
584 }
585
586 pub async fn sign_with(self, signer: &Signer) -> std::result::Result<String, SigningError> {
592 let components = self.sign_internal(signer).await?;
593 Ok(components.signed_url)
594 }
595}
596
597struct SignedUrlEndpoint {
599 scheme: String,
600 host: String,
601 host_with_port: String,
602}
603
604impl SignedUrlEndpoint {
605 fn canonical_url(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
606 scope.canonical_url(&self.scheme, &self.host_with_port, url_style)
607 }
608
609 fn canonical_host(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
610 scope.bucket_host(&self.host, url_style)
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use chrono::DateTime;
618 use google_cloud_auth::credentials::service_account::Builder as ServiceAccount;
619 use google_cloud_auth::signer::{Result as SignResult, Signer, SigningError, SigningProvider};
620 use serde::Deserialize;
621 use std::collections::HashMap;
622 use tokio::time::Duration;
623
624 type TestResult = anyhow::Result<()>;
625
626 mockall::mock! {
627 #[derive(Debug)]
628 Signer {}
629
630 impl SigningProvider for Signer {
631 async fn client_email(&self) -> SignResult<String>;
632 async fn sign(&self, content: &[u8]) -> SignResult<bytes::Bytes>;
633 }
634 }
635
636 impl SignedUrlBuilder {
637 fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
639 self.timestamp = timestamp;
640 self
641 }
642 }
643
644 #[tokio::test]
645 async fn test_signed_url_builder() -> TestResult {
646 let mut mock = MockSigner::new();
647 mock.expect_client_email()
648 .return_once(|| Ok("test@example.com".to_string()));
649 mock.expect_sign()
650 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
651
652 let signer = Signer::from(mock);
653 let _ = SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", "test-object")
654 .with_method(http::Method::PUT)
655 .with_expiration(Duration::from_secs(3600))
656 .with_header("x-goog-meta-test", "value")
657 .with_query_param("test", "value")
658 .with_endpoint("https://storage.googleapis.com")
659 .with_client_email("test@example.com")
660 .with_url_style(UrlStyle::PathStyle)
661 .sign_with(&signer)
662 .await?;
663
664 Ok(())
665 }
666
667 #[tokio::test]
668 async fn test_signed_url_error_signing() -> TestResult {
669 let mut mock = MockSigner::new();
670 mock.expect_client_email()
671 .return_once(|| Ok("test@example.com".to_string()));
672 mock.expect_sign()
673 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
674
675 let signer = Signer::from(mock);
676 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
677 .sign_with(&signer)
678 .await
679 .unwrap_err();
680
681 assert!(err.is_signing());
682
683 Ok(())
684 }
685
686 #[tokio::test]
687 async fn test_signed_url_error_endpoint() -> TestResult {
688 let mut mock = MockSigner::new();
689 mock.expect_client_email()
690 .return_once(|| Ok("test@example.com".to_string()));
691 mock.expect_sign()
692 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
693
694 let signer = Signer::from(mock);
695 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
696 .with_endpoint("invalid url")
697 .sign_with(&signer)
698 .await
699 .unwrap_err();
700
701 assert!(err.is_invalid_parameter());
702 assert!(err.to_string().contains("invalid `endpoint` parameter"));
703
704 Ok(())
705 }
706
707 #[tokio::test]
708 async fn test_signed_url_error_bucket() -> TestResult {
709 let mut mock = MockSigner::new();
710 mock.expect_client_email()
711 .return_once(|| Ok("test@example.com".to_string()));
712 mock.expect_sign()
713 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
714
715 let signer = Signer::from(mock);
716 let err = SignedUrlBuilder::for_object("invalid-bucket-name", "o")
717 .sign_with(&signer)
718 .await
719 .unwrap_err();
720
721 assert!(err.is_invalid_parameter());
722 assert!(err.to_string().contains("malformed bucket name"));
723
724 Ok(())
725 }
726
727 #[tokio::test]
728 async fn sign_with_is_send() -> TestResult {
729 fn assert_send<T: Send>(_t: &T) {}
730
731 let mut mock = MockSigner::new();
732 mock.expect_client_email()
733 .return_once(|| Ok("test@example.com".to_string()));
734 mock.expect_sign()
735 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
736
737 let signer = Signer::from(mock);
738 let fut = SignedUrlBuilder::for_object("projects/_/buckets/b", "o").sign_with(&signer);
739
740 assert_send(&fut);
741
742 Ok(())
743 }
744
745 #[test_case::test_case(
746 Some("path/with/slashes/under_score/amper&sand/file.ext"),
747 None,
748 UrlStyle::PathStyle,
749 "https://storage.googleapis.com/test-bucket/path/with/slashes/under_score/amper%26sand/file.ext"
750 ; "escape object name")]
751 #[test_case::test_case(
752 Some("folder/test object.txt"),
753 None,
754 UrlStyle::PathStyle,
755 "https://storage.googleapis.com/test-bucket/folder/test%20object.txt"
756 ; "escape object name with spaces")]
757 #[test_case::test_case(
758 Some("test-object"),
759 None,
760 UrlStyle::VirtualHostedStyle,
761 "https://test-bucket.storage.googleapis.com/test-object"
762 ; "virtual hosted style")]
763 #[test_case::test_case(
764 Some("test-object"),
765 Some("http://mydomain.tld"),
766 UrlStyle::BucketBoundHostname,
767 "http://mydomain.tld/test-object"
768 ; "bucket bound style")]
769 #[test_case::test_case(
770 None,
771 None,
772 UrlStyle::PathStyle,
773 "https://storage.googleapis.com/test-bucket"
774 ; "list objects")]
775 fn test_signed_url_canonical_url(
776 object: Option<&str>,
777 endpoint: Option<&str>,
778 url_style: UrlStyle,
779 expected_url: &str,
780 ) -> TestResult {
781 let builder = if let Some(object) = object {
782 SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", object)
783 } else {
784 SignedUrlBuilder::for_bucket("projects/_/buckets/test-bucket")
785 };
786 let builder = builder.with_url_style(url_style);
787 let builder = endpoint.iter().fold(builder, |builder, endpoint| {
788 builder.with_endpoint(*endpoint)
789 });
790
791 let endpoint = builder.resolve_endpoint_url()?;
792 let url = endpoint.canonical_url(&builder.scope, builder.url_style);
793 assert_eq!(url, expected_url);
794
795 Ok(())
796 }
797
798 #[derive(Deserialize)]
799 #[serde(rename_all = "camelCase")]
800 struct SignedUrlTestSuite {
801 signing_v4_tests: Vec<SignedUrlTest>,
802 }
803
804 #[derive(Deserialize)]
805 #[serde(rename_all = "camelCase")]
806 struct SignedUrlTest {
807 description: String,
808 bucket: String,
809 object: Option<String>,
810 method: String,
811 expiration: u64,
812 timestamp: String,
813 expected_url: String,
814 headers: Option<HashMap<String, String>>,
815 query_parameters: Option<HashMap<String, String>>,
816 scheme: Option<String>,
817 url_style: Option<String>,
818 bucket_bound_hostname: Option<String>,
819 expected_canonical_request: String,
820 expected_string_to_sign: String,
821 hostname: Option<String>,
822 client_endpoint: Option<String>,
823 emulator_hostname: Option<String>,
824 universe_domain: Option<String>,
825 }
826
827 #[tokio::test]
828 async fn signed_url_conformance() -> anyhow::Result<()> {
829 let service_account_key = serde_json::from_slice(include_bytes!(
830 "conformance/test_service_account.not-a-test.json"
831 ))?;
832
833 let signer = ServiceAccount::new(service_account_key)
834 .build_signer()
835 .expect("failed to build signer");
836
837 let suite: SignedUrlTestSuite =
838 serde_json::from_slice(include_bytes!("conformance/v4_signatures.json"))?;
839
840 let mut failed_tests = Vec::new();
841 let mut skipped_tests = Vec::new();
842 let mut passed_tests = Vec::new();
843 let total_tests = suite.signing_v4_tests.len();
844 for test in suite.signing_v4_tests {
845 let timestamp =
846 DateTime::parse_from_rfc3339(&test.timestamp).expect("invalid timestamp");
847 let method = http::Method::from_bytes(test.method.as_bytes()).expect("invalid method");
848 let scheme = test.scheme.unwrap_or("https".to_string());
849 let url_style = match test.url_style {
850 Some(url_style) => match url_style.as_str() {
851 "VIRTUAL_HOSTED_STYLE" => UrlStyle::VirtualHostedStyle,
852 "BUCKET_BOUND_HOSTNAME" => UrlStyle::BucketBoundHostname,
853 _ => UrlStyle::PathStyle,
854 },
855 None => UrlStyle::PathStyle,
856 };
857
858 let bucket = format!("projects/_/buckets/{}", test.bucket);
859 let builder = match test.object {
860 Some(object) => SignedUrlBuilder::for_object(bucket, object),
861 None => SignedUrlBuilder::for_bucket(bucket),
862 };
863
864 if test.emulator_hostname.is_some() {
865 skipped_tests.push(test.description);
866 continue;
867 }
868
869 let builder = builder
870 .with_method(method)
871 .with_url_style(url_style)
872 .with_expiration(Duration::from_secs(test.expiration))
873 .with_timestamp(timestamp.into());
874
875 let builder = test
876 .universe_domain
877 .iter()
878 .fold(builder, |builder, universe_domain| {
879 builder.with_endpoint(format!("https://storage.{}", universe_domain))
880 });
881 let builder = test
882 .client_endpoint
883 .iter()
884 .fold(builder, |builder, client_endpoint| {
885 builder.with_endpoint(client_endpoint)
886 });
887 let builder = test
888 .bucket_bound_hostname
889 .iter()
890 .fold(builder, |builder, hostname| {
891 builder.with_endpoint(format!("{}://{}", scheme, hostname))
892 });
893 let builder = test.hostname.iter().fold(builder, |builder, hostname| {
894 builder.with_endpoint(format!("{}://{}", scheme, hostname))
895 });
896 let builder = test.headers.iter().fold(builder, |builder, headers| {
897 headers.iter().fold(builder, |builder, (k, v)| {
898 builder.with_header(k.clone(), v.clone())
899 })
900 });
901 let builder = test
902 .query_parameters
903 .iter()
904 .fold(builder, |builder, query_params| {
905 query_params.iter().fold(builder, |builder, (k, v)| {
906 builder.with_query_param(k.clone(), v.clone())
907 })
908 });
909
910 let components = builder.sign_internal(&signer).await;
911 let components = match components {
912 Ok(components) => components,
913 Err(e) => {
914 println!("❌ Failed test: {}", test.description);
915 println!("Error: {}", e);
916 failed_tests.push(test.description);
917 continue;
918 }
919 };
920
921 let canonical_request = components.canonical_request;
922 let string_to_sign = components.string_to_sign;
923 let signed_url = components.signed_url;
924
925 if canonical_request != test.expected_canonical_request
926 || string_to_sign != test.expected_string_to_sign
927 || signed_url != test.expected_url
928 {
929 println!("❌ Failed test: {}", test.description);
930 let diff = pretty_assertions::StrComparison::new(
931 &canonical_request,
932 &test.expected_canonical_request,
933 );
934 println!("Canonical request diff: {}", diff);
935 let diff = pretty_assertions::StrComparison::new(
936 &string_to_sign,
937 &test.expected_string_to_sign,
938 );
939 println!("String to sign diff: {}", diff);
940 let diff = pretty_assertions::StrComparison::new(&signed_url, &test.expected_url);
941 println!("Signed URL diff: {}", diff);
942 failed_tests.push(test.description);
943 continue;
944 }
945 passed_tests.push(test.description);
946 }
947
948 let failed = !failed_tests.is_empty();
949 let total_passed = passed_tests.len();
950 for test in passed_tests {
951 println!("✅ Passed test: {}", test);
952 }
953 for test in skipped_tests {
954 println!("🟡 Skipped test: {}", test);
955 }
956 for test in failed_tests {
957 println!("❌ Failed test: {}", test);
958 }
959 println!("{}/{} tests passed", total_passed, total_tests);
960
961 if failed {
962 anyhow::bail!("Some tests failed")
963 }
964 Ok(())
965 }
966}