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 mut canonical_query = url::form_urlencoded::Serializer::new("".to_string());
527 for (k, v) in &query_parameters {
528 canonical_query.append_pair(k, v);
529 }
530
531 let canonical_query = canonical_query.finish();
532 let canonical_query = canonical_query
533 .replace("%7E", "~") .replace("+", "%20"); let canonical_headers = headers.iter().fold("".to_string(), |acc, (k, v)| {
537 let header_value = Self::canonicalize_header_value(v);
538 format!("{acc}{}:{}\n", k, header_value)
539 });
540
541 let signature = headers
544 .get("x-goog-content-sha256")
545 .cloned()
546 .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string());
547
548 let canonical_uri = self.scope.canonical_uri(self.url_style);
549 let canonical_request = [
550 self.method.to_string(),
551 canonical_uri.clone(),
552 canonical_query.clone(),
553 canonical_headers,
554 signed_headers,
555 signature,
556 ]
557 .join("\n");
558
559 let canonical_request_hash = Sha256::digest(canonical_request.as_bytes());
560 let canonical_request_hash = hex::encode(canonical_request_hash);
561
562 let string_to_sign = [
563 "GOOG4-RSA-SHA256".to_string(),
564 request_timestamp,
565 credential_scope,
566 canonical_request_hash,
567 ]
568 .join("\n");
569
570 let signature = signer
571 .sign(string_to_sign.as_str())
572 .await
573 .map_err(SigningError::signing)?;
574
575 let signature = hex::encode(signature);
576
577 let signed_url = format!(
578 "{}?{}&X-Goog-Signature={}",
579 canonical_url, canonical_query, signature
580 );
581
582 Ok(SigningComponents {
583 #[cfg(test)]
584 canonical_request,
585 #[cfg(test)]
586 string_to_sign,
587 signed_url,
588 })
589 }
590
591 pub async fn sign_with(self, signer: &Signer) -> std::result::Result<String, SigningError> {
597 let components = self.sign_internal(signer).await?;
598 Ok(components.signed_url)
599 }
600}
601
602struct SignedUrlEndpoint {
604 scheme: String,
605 host: String,
606 host_with_port: String,
607}
608
609impl SignedUrlEndpoint {
610 fn canonical_url(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
611 scope.canonical_url(&self.scheme, &self.host_with_port, url_style)
612 }
613
614 fn canonical_host(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
615 scope.bucket_host(&self.host, url_style)
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use chrono::DateTime;
623 use google_cloud_auth::credentials::service_account::Builder as ServiceAccount;
624 use google_cloud_auth::signer::{Result as SignResult, Signer, SigningError, SigningProvider};
625 use serde::Deserialize;
626 use std::collections::HashMap;
627 use tokio::time::Duration;
628
629 type TestResult = anyhow::Result<()>;
630
631 mockall::mock! {
632 #[derive(Debug)]
633 Signer {}
634
635 impl SigningProvider for Signer {
636 async fn client_email(&self) -> SignResult<String>;
637 async fn sign(&self, content: &[u8]) -> SignResult<bytes::Bytes>;
638 }
639 }
640
641 #[tokio::test]
642 async fn test_signed_url_builder() -> TestResult {
643 let mut mock = MockSigner::new();
644 mock.expect_client_email()
645 .return_once(|| Ok("test@example.com".to_string()));
646 mock.expect_sign()
647 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
648
649 let signer = Signer::from(mock);
650 let _ = SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", "test-object")
651 .with_method(http::Method::PUT)
652 .with_expiration(Duration::from_secs(3600))
653 .with_header("x-goog-meta-test", "value")
654 .with_query_param("test", "value")
655 .with_endpoint("https://storage.googleapis.com")
656 .with_client_email("test@example.com")
657 .with_url_style(UrlStyle::PathStyle)
658 .sign_with(&signer)
659 .await?;
660
661 Ok(())
662 }
663
664 #[tokio::test]
665 async fn test_signed_url_error_signing() -> TestResult {
666 let mut mock = MockSigner::new();
667 mock.expect_client_email()
668 .return_once(|| Ok("test@example.com".to_string()));
669 mock.expect_sign()
670 .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
671
672 let signer = Signer::from(mock);
673 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
674 .sign_with(&signer)
675 .await
676 .unwrap_err();
677
678 assert!(err.is_signing());
679
680 Ok(())
681 }
682
683 #[tokio::test]
684 async fn test_signed_url_error_endpoint() -> TestResult {
685 let mut mock = MockSigner::new();
686 mock.expect_client_email()
687 .return_once(|| Ok("test@example.com".to_string()));
688 mock.expect_sign()
689 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
690
691 let signer = Signer::from(mock);
692 let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
693 .with_endpoint("invalid url")
694 .sign_with(&signer)
695 .await
696 .unwrap_err();
697
698 assert!(err.is_invalid_parameter());
699 assert!(err.to_string().contains("invalid `endpoint` parameter"));
700
701 Ok(())
702 }
703
704 #[tokio::test]
705 async fn test_signed_url_error_bucket() -> TestResult {
706 let mut mock = MockSigner::new();
707 mock.expect_client_email()
708 .return_once(|| Ok("test@example.com".to_string()));
709 mock.expect_sign()
710 .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
711
712 let signer = Signer::from(mock);
713 let err = SignedUrlBuilder::for_object("invalid-bucket-name", "o")
714 .sign_with(&signer)
715 .await
716 .unwrap_err();
717
718 assert!(err.is_invalid_parameter());
719 assert!(err.to_string().contains("malformed bucket name"));
720
721 Ok(())
722 }
723
724 #[test_case::test_case(
725 Some("path/with/slashes/under_score/amper&sand/file.ext"),
726 None,
727 UrlStyle::PathStyle,
728 "https://storage.googleapis.com/test-bucket/path/with/slashes/under_score/amper%26sand/file.ext"
729 ; "escape object name")]
730 #[test_case::test_case(
731 Some("folder/test object.txt"),
732 None,
733 UrlStyle::PathStyle,
734 "https://storage.googleapis.com/test-bucket/folder/test%20object.txt"
735 ; "escape object name with spaces")]
736 #[test_case::test_case(
737 Some("test-object"),
738 None,
739 UrlStyle::VirtualHostedStyle,
740 "https://test-bucket.storage.googleapis.com/test-object"
741 ; "virtual hosted style")]
742 #[test_case::test_case(
743 Some("test-object"),
744 Some("http://mydomain.tld"),
745 UrlStyle::BucketBoundHostname,
746 "http://mydomain.tld/test-object"
747 ; "bucket bound style")]
748 #[test_case::test_case(
749 None,
750 None,
751 UrlStyle::PathStyle,
752 "https://storage.googleapis.com/test-bucket"
753 ; "list objects")]
754 fn test_signed_url_canonical_url(
755 object: Option<&str>,
756 endpoint: Option<&str>,
757 url_style: UrlStyle,
758 expected_url: &str,
759 ) -> TestResult {
760 let builder = if let Some(object) = object {
761 SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", object)
762 } else {
763 SignedUrlBuilder::for_bucket("projects/_/buckets/test-bucket")
764 };
765 let builder = builder.with_url_style(url_style);
766 let builder = endpoint.iter().fold(builder, |builder, endpoint| {
767 builder.with_endpoint(*endpoint)
768 });
769
770 let endpoint = builder.resolve_endpoint_url()?;
771 let url = endpoint.canonical_url(&builder.scope, builder.url_style);
772 assert_eq!(url, expected_url);
773
774 Ok(())
775 }
776
777 #[derive(Deserialize)]
778 #[serde(rename_all = "camelCase")]
779 struct SignedUrlTestSuite {
780 signing_v4_tests: Vec<SignedUrlTest>,
781 }
782
783 #[derive(Deserialize)]
784 #[serde(rename_all = "camelCase")]
785 struct SignedUrlTest {
786 description: String,
787 bucket: String,
788 object: Option<String>,
789 method: String,
790 expiration: u64,
791 timestamp: String,
792 expected_url: String,
793 headers: Option<HashMap<String, String>>,
794 query_parameters: Option<HashMap<String, String>>,
795 scheme: Option<String>,
796 url_style: Option<String>,
797 bucket_bound_hostname: Option<String>,
798 expected_canonical_request: String,
799 expected_string_to_sign: String,
800 hostname: Option<String>,
801 client_endpoint: Option<String>,
802 emulator_hostname: Option<String>,
803 universe_domain: Option<String>,
804 }
805
806 #[tokio::test]
807 async fn signed_url_conformance() -> anyhow::Result<()> {
808 let service_account_key = serde_json::from_slice(include_bytes!(
809 "conformance/test_service_account.not-a-test.json"
810 ))?;
811
812 let signer = ServiceAccount::new(service_account_key)
813 .build_signer()
814 .expect("failed to build signer");
815
816 let suite: SignedUrlTestSuite =
817 serde_json::from_slice(include_bytes!("conformance/v4_signatures.json"))?;
818
819 let mut failed_tests = Vec::new();
820 let mut skipped_tests = Vec::new();
821 let mut passed_tests = Vec::new();
822 let total_tests = suite.signing_v4_tests.len();
823 for test in suite.signing_v4_tests {
824 let timestamp =
825 DateTime::parse_from_rfc3339(&test.timestamp).expect("invalid timestamp");
826 let method = http::Method::from_bytes(test.method.as_bytes()).expect("invalid method");
827 let scheme = test.scheme.unwrap_or("https".to_string());
828 let url_style = match test.url_style {
829 Some(url_style) => match url_style.as_str() {
830 "VIRTUAL_HOSTED_STYLE" => UrlStyle::VirtualHostedStyle,
831 "BUCKET_BOUND_HOSTNAME" => UrlStyle::BucketBoundHostname,
832 _ => UrlStyle::PathStyle,
833 },
834 None => UrlStyle::PathStyle,
835 };
836
837 let bucket = format!("projects/_/buckets/{}", test.bucket);
838 let builder = match test.object {
839 Some(object) => SignedUrlBuilder::for_object(bucket, object),
840 None => SignedUrlBuilder::for_bucket(bucket),
841 };
842
843 if test.emulator_hostname.is_some() {
844 skipped_tests.push(test.description);
845 continue;
846 }
847
848 let builder = builder
849 .with_method(method)
850 .with_url_style(url_style)
851 .with_expiration(Duration::from_secs(test.expiration))
852 .with_timestamp(timestamp.into());
853
854 let builder = test
855 .universe_domain
856 .iter()
857 .fold(builder, |builder, universe_domain| {
858 builder.with_endpoint(format!("https://storage.{}", universe_domain))
859 });
860 let builder = test
861 .client_endpoint
862 .iter()
863 .fold(builder, |builder, client_endpoint| {
864 builder.with_endpoint(client_endpoint)
865 });
866 let builder = test
867 .bucket_bound_hostname
868 .iter()
869 .fold(builder, |builder, hostname| {
870 builder.with_endpoint(format!("{}://{}", scheme, hostname))
871 });
872 let builder = test.hostname.iter().fold(builder, |builder, hostname| {
873 builder.with_endpoint(format!("{}://{}", scheme, hostname))
874 });
875 let builder = test.headers.iter().fold(builder, |builder, headers| {
876 headers.iter().fold(builder, |builder, (k, v)| {
877 builder.with_header(k.clone(), v.clone())
878 })
879 });
880 let builder = test
881 .query_parameters
882 .iter()
883 .fold(builder, |builder, query_params| {
884 query_params.iter().fold(builder, |builder, (k, v)| {
885 builder.with_query_param(k.clone(), v.clone())
886 })
887 });
888
889 let components = builder.sign_internal(&signer).await;
890 let components = match components {
891 Ok(components) => components,
892 Err(e) => {
893 println!("❌ Failed test: {}", test.description);
894 println!("Error: {}", e);
895 failed_tests.push(test.description);
896 continue;
897 }
898 };
899
900 let canonical_request = components.canonical_request;
901 let string_to_sign = components.string_to_sign;
902 let signed_url = components.signed_url;
903
904 if canonical_request != test.expected_canonical_request
905 || string_to_sign != test.expected_string_to_sign
906 || signed_url != test.expected_url
907 {
908 println!("❌ Failed test: {}", test.description);
909 let diff = pretty_assertions::StrComparison::new(
910 &canonical_request,
911 &test.expected_canonical_request,
912 );
913 println!("Canonical request diff: {}", diff);
914 let diff = pretty_assertions::StrComparison::new(
915 &string_to_sign,
916 &test.expected_string_to_sign,
917 );
918 println!("String to sign diff: {}", diff);
919 let diff = pretty_assertions::StrComparison::new(&signed_url, &test.expected_url);
920 println!("Signed URL diff: {}", diff);
921 failed_tests.push(test.description);
922 continue;
923 }
924 passed_tests.push(test.description);
925 }
926
927 let failed = !failed_tests.is_empty();
928 let total_passed = passed_tests.len();
929 for test in passed_tests {
930 println!("✅ Passed test: {}", test);
931 }
932 for test in skipped_tests {
933 println!("🟡 Skipped test: {}", test);
934 }
935 for test in failed_tests {
936 println!("❌ Failed test: {}", test);
937 }
938 println!("{}/{} tests passed", total_passed, total_tests);
939
940 if failed {
941 anyhow::bail!("Some tests failed")
942 }
943 Ok(())
944 }
945}