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    /// Sets the HTTP method for the signed URL. The default is [GET][crate::http::Method::GET].
282    ///
283    /// # Example
284    ///
285    /// ```
286    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
287    /// # use google_cloud_auth::signer::Signer;
288    /// use google_cloud_storage::http;
289    ///
290    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
291    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
292    ///         .with_method(http::Method::PUT)
293    ///         .sign_with(signer)
294    ///         .await?;
295    /// # Ok(())
296    /// }
297    /// ```
298    pub fn with_method(mut self, method: http::Method) -> Self {
299        self.method = method;
300        self
301    }
302
303    /// Sets the expiration time for the signed URL. The default is 7 days.
304    ///
305    /// The maximum expiration time for V4 signed URLs is 7 days.
306    ///
307    /// # Example
308    ///
309    /// ```
310    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
311    /// use std::time::Duration;
312    /// # use google_cloud_auth::signer::Signer;
313    ///
314    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
315    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
316    ///         .with_expiration(Duration::from_secs(3600))
317    ///         .sign_with(signer)
318    ///         .await?;
319    /// # Ok(())
320    /// }
321    /// ```
322    pub fn with_expiration(mut self, expiration: std::time::Duration) -> Self {
323        self.expiration = expiration;
324        self
325    }
326
327    /// Sets the URL style for the signed URL. The default is `UrlStyle::PathStyle`.
328    ///
329    /// # Example
330    ///
331    /// ```
332    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
333    /// use google_cloud_storage::signed_url::UrlStyle;
334    /// # use google_cloud_auth::signer::Signer;
335    ///
336    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
337    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
338    ///         .with_url_style(UrlStyle::VirtualHostedStyle)
339    ///         .sign_with(signer)
340    ///         .await?;
341    /// # Ok(())
342    /// }
343    /// ```
344    pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
345        self.url_style = url_style;
346        self
347    }
348
349    /// Adds a header to the signed URL.
350    ///
351    /// Subsequent calls to this method with the same key will override the previous value.
352    ///
353    /// Note: These headers must be present in the request when using the signed URL.
354    ///
355    ///
356    /// # Example
357    ///
358    /// ```
359    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
360    /// # use google_cloud_auth::signer::Signer;
361    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
362    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
363    ///         .with_header("content-type", "text/plain")
364    ///         .sign_with(signer)
365    ///         .await?;
366    /// # Ok(())
367    /// }
368    /// ```
369    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    /// Adds a query parameter to the signed URL.
375    ///
376    /// Subsequent calls to this method with the same key will override the previous value.
377    ///
378    /// # Example
379    /// ```
380    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
381    /// # use google_cloud_auth::signer::Signer;
382    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
383    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
384    ///         .with_query_param("generation", "1234567890")
385    ///         .sign_with(signer)
386    ///         .await?;
387    /// # Ok(())
388    /// }
389    /// ```
390    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    /// Sets the endpoint for the signed URL. The default is `"https://storage.googleapis.com"`.
396    ///
397    /// This is useful when using a custom domain, or when testing with some Cloud Storage emulators.
398    ///
399    /// # Example
400    ///
401    /// ```
402    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
403    /// # use google_cloud_auth::signer::Signer;
404    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
405    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
406    ///         .with_endpoint("https://private.googleapis.com")
407    ///         .sign_with(signer)
408    ///         .await?;
409    /// # Ok(())
410    /// }
411    /// ```
412    pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
413        self.endpoint = Some(endpoint.into());
414        self
415    }
416
417    /// Sets the client email for the signed URL.
418    ///
419    /// If not set, the email will be fetched from the signer.
420    ///
421    /// # Example
422    ///
423    /// ```
424    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
425    /// # use google_cloud_auth::signer::Signer;
426    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
427    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
428    ///         .with_client_email("my-service-account@my-project.iam.gserviceaccount.com")
429    ///         .sign_with(signer)
430    ///         .await?;
431    /// # Ok(())
432    /// }
433    /// ```
434    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        // Extract host and port exactly as they appear in the endpoint.
448        // We do this because the url crate omits default ports (80/443),
449        // but GCS requires them to be maintained if explicitly provided.
450        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    /// Generates the signed URL using the provided signer.
476    /// Returns the signed URL, the string to sign, and the canonical request.
477    /// Used to check conformance test expectations.
478    async fn sign_internal(
479        self,
480        signer: &Signer,
481    ) -> std::result::Result<SigningComponents, SigningError> {
482        // Validate the bucket name.
483        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", "~") // rollback to ~
528                .replace("+", "%20") // missing %20 in +
529        };
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        // If the user provides a value for X-Goog-Content-SHA256, we must use
537        // that value in the request string. If not, we use UNSIGNED-PAYLOAD.
538        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    /// Generates the signed URL using the provided signer.
587    ///
588    /// # Returns
589    ///
590    /// A `Result` containing the signed URL as a `String` or a `SigningError`.
591    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
597/// The resolved endpoint for a signed URL.
598struct 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        /// Sets the timestamp for the signed URL. Only used in tests.
638        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}