Skip to main content

google_cloud_storage/storage/
signed_url.rs

1// Copyright 2025 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
22/// Same encoding set as used in https://cloud.google.com/storage/docs/request-endpoints#encoding
23/// but for signed URLs, we do not encode '/'.
24const PATH_ENCODE_SET: AsciiSet = ENCODED_CHARS.remove(b'/');
25
26/// Creates [Signed URLs].
27///
28/// This builder allows you to generate signed URLs for Google Cloud Storage objects and buckets.
29/// [Signed URLs] provide a way to give time-limited read or write access to specific resources
30/// without sharing your credentials.
31///
32/// This implementation uses the [V4 signing process].
33///
34/// # Example: Generating a Signed URL
35///
36/// ## Generating a Signed URL for Downloading an Object (GET)
37///
38/// ```
39/// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
40/// use std::time::Duration;
41/// use google_cloud_auth::signer::Signer;
42/// # async fn run(signer: &Signer) -> anyhow::Result<()> {
43/// let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
44///     .with_method(http::Method::GET)
45///     .with_expiration(Duration::from_secs(3600)) // 1 hour
46///     .sign_with(signer)
47///     .await?;
48///
49/// println!("Signed URL: {}", url);
50/// # Ok(())
51/// # }
52/// ```
53///
54/// ## Generating a Signed URL for Uploading an Object (PUT)
55///
56/// ```
57/// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
58/// use std::time::Duration;
59/// # use google_cloud_auth::signer::Signer;
60///
61/// # async fn run(signer: &Signer) -> anyhow::Result<()> {
62/// let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
63///     .with_method(http::Method::PUT)
64///     .with_expiration(Duration::from_secs(3600)) // 1 hour
65///     .with_header("content-type", "application/json") // Optional: Enforce content type
66///     .sign_with(signer)
67///     .await?;
68///
69/// println!("Upload URL: {}", url);
70/// # Ok(())
71/// # }
72/// ```
73///
74/// # Example: Creating a Signer
75///
76/// You can use `google-cloud-auth` to create a `Signer`.
77///
78/// ## Using [Application Default Credentials] (ADC)
79///
80/// This is the recommended way for most applications. It automatically finds credentials
81/// from the environment. See how [Application Default Credentials] works.
82///
83/// ```
84/// use google_cloud_auth::credentials::Builder;
85/// use google_cloud_auth::signer::Signer;
86///
87/// # fn build_signer() -> anyhow::Result<()> {
88/// let signer = Builder::default().build_signer()?;
89/// # Ok(())
90/// # }
91/// ```
92///
93/// ## Using a Service Account Key File
94///
95/// This is useful when you have a specific service account key file (JSON) and want to use it directly.
96/// Service account based signers work by local signing and do not make network requests, which can be
97/// useful in environments where network access is restricted and performance is critical.
98///
99/// <div class="warning">
100///     <strong>Caution:</strong> Service account keys are a security risk if not managed correctly.
101///     See <a href="https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys">
102///     Best practices for managing service account keys</a> for more information.
103/// </div>
104///
105/// ```
106/// use google_cloud_auth::credentials::service_account::Builder;
107/// use google_cloud_auth::signer::Signer;
108///
109/// # async fn build_signer() -> anyhow::Result<()> {
110/// let service_account_key = serde_json::json!({ /* add details here */ });
111///
112/// let signer = Builder::new(service_account_key).build_signer()?;
113/// # Ok(())
114/// # }
115/// ```
116///
117/// [Application Default Credentials]: https://docs.cloud.google.com/docs/authentication/application-default-credentials
118/// [signed urls]: https://docs.cloud.google.com/storage/docs/access-control/signed-urls
119/// [V4 signing process]: https://docs.cloud.google.com/storage/docs/access-control/signed-urls
120#[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
214// Used to check conformance test expectations.
215struct 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), // 7 days
229            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    /// Creates a new `SignedUrlBuilder` for a specific object.
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
244    /// # use google_cloud_auth::signer::Signer;
245    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
246    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
247    ///         .sign_with(signer)
248    ///         .await?;
249    /// # Ok(())
250    /// }
251    ///```
252    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    /// Creates a new `SignedUrlBuilder` for a specific bucket.
261    ///
262    /// # Example
263    ///
264    /// ```
265    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
266    /// # use google_cloud_auth::signer::Signer;
267    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
268    ///     let url = SignedUrlBuilder::for_bucket("projects/_/buckets/my-bucket")
269    ///         .sign_with(signer)
270    ///         .await?;
271    /// # Ok(())
272    /// }
273    /// ```
274    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    /// Sets the timestamp for the signed URL. Only used in tests.
283    fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
284        self.timestamp = timestamp;
285        self
286    }
287
288    /// Sets the HTTP method for the signed URL. The default is [GET][crate::http::Method::GET].
289    ///
290    /// # Example
291    ///
292    /// ```
293    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
294    /// # use google_cloud_auth::signer::Signer;
295    /// use google_cloud_storage::http;
296    ///
297    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
298    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
299    ///         .with_method(http::Method::PUT)
300    ///         .sign_with(signer)
301    ///         .await?;
302    /// # Ok(())
303    /// }
304    /// ```
305    pub fn with_method(mut self, method: http::Method) -> Self {
306        self.method = method;
307        self
308    }
309
310    /// Sets the expiration time for the signed URL. The default is 7 days.
311    ///
312    /// The maximum expiration time for V4 signed URLs is 7 days.
313    ///
314    /// # Example
315    ///
316    /// ```
317    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
318    /// use std::time::Duration;
319    /// # use google_cloud_auth::signer::Signer;
320    ///
321    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
322    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
323    ///         .with_expiration(Duration::from_secs(3600))
324    ///         .sign_with(signer)
325    ///         .await?;
326    /// # Ok(())
327    /// }
328    /// ```
329    pub fn with_expiration(mut self, expiration: std::time::Duration) -> Self {
330        self.expiration = expiration;
331        self
332    }
333
334    /// Sets the URL style for the signed URL. The default is `UrlStyle::PathStyle`.
335    ///
336    /// # Example
337    ///
338    /// ```
339    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
340    /// use google_cloud_storage::signed_url::UrlStyle;
341    /// # use google_cloud_auth::signer::Signer;
342    ///
343    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
344    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
345    ///         .with_url_style(UrlStyle::VirtualHostedStyle)
346    ///         .sign_with(signer)
347    ///         .await?;
348    /// # Ok(())
349    /// }
350    /// ```
351    pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
352        self.url_style = url_style;
353        self
354    }
355
356    /// Adds a header to the signed URL.
357    ///
358    /// Subsequent calls to this method with the same key will override the previous value.
359    ///
360    /// Note: These headers must be present in the request when using the signed URL.
361    ///
362    ///
363    /// # Example
364    ///
365    /// ```
366    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
367    /// # use google_cloud_auth::signer::Signer;
368    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
369    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
370    ///         .with_header("content-type", "text/plain")
371    ///         .sign_with(signer)
372    ///         .await?;
373    /// # Ok(())
374    /// }
375    /// ```
376    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    /// Adds a query parameter to the signed URL.
382    ///
383    /// Subsequent calls to this method with the same key will override the previous value.
384    ///
385    /// # Example
386    /// ```
387    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
388    /// # use google_cloud_auth::signer::Signer;
389    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
390    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
391    ///         .with_query_param("generation", "1234567890")
392    ///         .sign_with(signer)
393    ///         .await?;
394    /// # Ok(())
395    /// }
396    /// ```
397    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    /// Sets the endpoint for the signed URL. The default is `"https://storage.googleapis.com"`.
403    ///
404    /// This is useful when using a custom domain, or when testing with some Cloud Storage emulators.
405    ///
406    /// # Example
407    ///
408    /// ```
409    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
410    /// # use google_cloud_auth::signer::Signer;
411    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
412    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
413    ///         .with_endpoint("https://private.googleapis.com")
414    ///         .sign_with(signer)
415    ///         .await?;
416    /// # Ok(())
417    /// }
418    /// ```
419    pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
420        self.endpoint = Some(endpoint.into());
421        self
422    }
423
424    /// Sets the client email for the signed URL.
425    ///
426    /// If not set, the email will be fetched from the signer.
427    ///
428    /// # Example
429    ///
430    /// ```
431    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
432    /// # use google_cloud_auth::signer::Signer;
433    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
434    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
435    ///         .with_client_email("my-service-account@my-project.iam.gserviceaccount.com")
436    ///         .sign_with(signer)
437    ///         .await?;
438    /// # Ok(())
439    /// }
440    /// ```
441    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        // Extract host and port exactly as they appear in the endpoint.
455        // We do this because the url crate omits default ports (80/443),
456        // but GCS requires them to be maintained if explicitly provided.
457        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    /// Generates the signed URL using the provided signer.
483    /// Returns the signed URL, the string to sign, and the canonical request.
484    /// Used to check conformance test expectations.
485    async fn sign_internal(
486        self,
487        signer: &Signer,
488    ) -> std::result::Result<SigningComponents, SigningError> {
489        // Validate the bucket name.
490        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", "~") // rollback to ~
535                .replace("+", "%20") // missing %20 in +
536        };
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        // If the user provides a value for X-Goog-Content-SHA256, we must use
544        // that value in the request string. If not, we use UNSIGNED-PAYLOAD.
545        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    /// Generates the signed URL using the provided signer.
594    ///
595    /// # Returns
596    ///
597    /// A `Result` containing the signed URL as a `String` or a `SigningError`.
598    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
604/// The resolved endpoint for a signed URL.
605struct 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}