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 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", "~") // rollback to ~
534            .replace("+", "%20"); // missing %20 in +
535
536        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        // If the user provides a value for X-Goog-Content-SHA256, we must use
542        // that value in the request string. If not, we use UNSIGNED-PAYLOAD.
543        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    /// Generates the signed URL using the provided signer.
592    ///
593    /// # Returns
594    ///
595    /// A `Result` containing the signed URL as a `String` or a `SigningError`.
596    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
602/// The resolved endpoint for a signed URL.
603struct 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}