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    universe_domain: Option<String>,
129    client_email: Option<String>,
130    timestamp: DateTime<Utc>,
131    url_style: UrlStyle,
132}
133
134#[derive(Debug)]
135enum SigningScope {
136    Bucket(String),
137    Object(String, String),
138}
139
140impl SigningScope {
141    fn check_bucket_name(&self) -> Result<(), SigningError> {
142        let bucket = match self {
143            SigningScope::Bucket(bucket) => bucket,
144            SigningScope::Object(bucket, _) => bucket,
145        };
146
147        bucket.strip_prefix("projects/_/buckets/").ok_or_else(|| {
148            SigningError::invalid_parameter(
149                "bucket",
150                format!(
151                    "malformed bucket name, it must start with `projects/_/buckets/`: {bucket}"
152                ),
153            )
154        })?;
155
156        Ok(())
157    }
158
159    fn bucket_name(&self) -> String {
160        let bucket = match self {
161            SigningScope::Bucket(bucket) => bucket,
162            SigningScope::Object(bucket, _) => bucket,
163        };
164
165        bucket.trim_start_matches("projects/_/buckets/").to_string()
166    }
167
168    fn bucket_host(&self, host: &str, url_style: UrlStyle) -> String {
169        match url_style {
170            UrlStyle::PathStyle => host.to_string(),
171            UrlStyle::BucketBoundHostname => host.to_string(),
172            UrlStyle::VirtualHostedStyle => format!("{}.{host}", self.bucket_name()),
173        }
174    }
175
176    fn bucket_endpoint(&self, scheme: &str, host: &str, url_style: UrlStyle) -> String {
177        let bucket_host = self.bucket_host(host, url_style);
178        format!("{scheme}://{bucket_host}")
179    }
180
181    fn canonical_uri(&self, url_style: UrlStyle) -> String {
182        let bucket_name = self.bucket_name();
183        match self {
184            SigningScope::Object(_, object) => {
185                let encoded_object = utf8_percent_encode(object, &PATH_ENCODE_SET);
186                match url_style {
187                    UrlStyle::PathStyle => {
188                        format!("/{bucket_name}/{encoded_object}")
189                    }
190                    UrlStyle::BucketBoundHostname => {
191                        format!("/{encoded_object}")
192                    }
193                    UrlStyle::VirtualHostedStyle => {
194                        format!("/{encoded_object}")
195                    }
196                }
197            }
198            SigningScope::Bucket(_) => match url_style {
199                UrlStyle::PathStyle => {
200                    format!("/{bucket_name}")
201                }
202                UrlStyle::BucketBoundHostname => "".to_string(),
203                UrlStyle::VirtualHostedStyle => "".to_string(),
204            },
205        }
206    }
207
208    fn canonical_url(&self, scheme: &str, host: &str, url_style: UrlStyle) -> String {
209        let bucket_endpoint = self.bucket_endpoint(scheme, host, url_style);
210        let uri = self.canonical_uri(url_style);
211        format!("{bucket_endpoint}{uri}")
212    }
213}
214
215// Used to check conformance test expectations.
216struct SigningComponents {
217    #[cfg(test)]
218    canonical_request: String,
219    #[cfg(test)]
220    string_to_sign: String,
221    signed_url: String,
222}
223
224impl SignedUrlBuilder {
225    fn new(scope: SigningScope) -> Self {
226        Self {
227            scope,
228            method: http::Method::GET,
229            expiration: std::time::Duration::from_secs(7 * 24 * 60 * 60), // 7 days
230            headers: BTreeMap::new(),
231            query_parameters: BTreeMap::new(),
232            endpoint: None,
233            universe_domain: None,
234            client_email: None,
235            timestamp: Utc::now(),
236            url_style: UrlStyle::PathStyle,
237        }
238    }
239
240    /// Creates a new `SignedUrlBuilder` for a specific object.
241    ///
242    /// # Example
243    ///
244    /// ```
245    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
246    /// # use google_cloud_auth::signer::Signer;
247    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
248    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
249    ///         .sign_with(signer)
250    ///         .await?;
251    /// # Ok(())
252    /// }
253    ///```
254    pub fn for_object<B, O>(bucket: B, object: O) -> Self
255    where
256        B: Into<String>,
257        O: Into<String>,
258    {
259        Self::new(SigningScope::Object(bucket.into(), object.into()))
260    }
261
262    /// Creates a new `SignedUrlBuilder` for a specific bucket.
263    ///
264    /// # Example
265    ///
266    /// ```
267    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
268    /// # use google_cloud_auth::signer::Signer;
269    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
270    ///     let url = SignedUrlBuilder::for_bucket("projects/_/buckets/my-bucket")
271    ///         .sign_with(signer)
272    ///         .await?;
273    /// # Ok(())
274    /// }
275    /// ```
276    pub fn for_bucket<B>(bucket: B) -> Self
277    where
278        B: Into<String>,
279    {
280        Self::new(SigningScope::Bucket(bucket.into()))
281    }
282
283    /// Sets the HTTP method for the signed URL. The default is [GET][crate::http::Method::GET].
284    ///
285    /// # Example
286    ///
287    /// ```
288    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
289    /// # use google_cloud_auth::signer::Signer;
290    /// use google_cloud_storage::http;
291    ///
292    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
293    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
294    ///         .with_method(http::Method::PUT)
295    ///         .sign_with(signer)
296    ///         .await?;
297    /// # Ok(())
298    /// }
299    /// ```
300    pub fn with_method(mut self, method: http::Method) -> Self {
301        self.method = method;
302        self
303    }
304
305    /// Sets the expiration time for the signed URL. The default is 7 days.
306    ///
307    /// The maximum expiration time for V4 signed URLs is 7 days.
308    ///
309    /// # Example
310    ///
311    /// ```
312    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
313    /// use std::time::Duration;
314    /// # use google_cloud_auth::signer::Signer;
315    ///
316    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
317    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
318    ///         .with_expiration(Duration::from_secs(3600))
319    ///         .sign_with(signer)
320    ///         .await?;
321    /// # Ok(())
322    /// }
323    /// ```
324    pub fn with_expiration(mut self, expiration: std::time::Duration) -> Self {
325        self.expiration = expiration;
326        self
327    }
328
329    /// Sets the URL style for the signed URL. The default is `UrlStyle::PathStyle`.
330    ///
331    /// # Example
332    ///
333    /// ```
334    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
335    /// use google_cloud_storage::signed_url::UrlStyle;
336    /// # use google_cloud_auth::signer::Signer;
337    ///
338    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
339    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
340    ///         .with_url_style(UrlStyle::VirtualHostedStyle)
341    ///         .sign_with(signer)
342    ///         .await?;
343    /// # Ok(())
344    /// }
345    /// ```
346    pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
347        self.url_style = url_style;
348        self
349    }
350
351    /// Adds a header to the signed URL.
352    ///
353    /// Subsequent calls to this method with the same key will override the previous value.
354    ///
355    /// Note: These headers must be present in the request when using the signed URL.
356    ///
357    ///
358    /// # Example
359    ///
360    /// ```
361    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
362    /// # use google_cloud_auth::signer::Signer;
363    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
364    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
365    ///         .with_header("content-type", "text/plain")
366    ///         .sign_with(signer)
367    ///         .await?;
368    /// # Ok(())
369    /// }
370    /// ```
371    pub fn with_header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
372        self.headers.insert(key.into().to_lowercase(), value.into());
373        self
374    }
375
376    /// Adds a query parameter to the signed URL.
377    ///
378    /// Subsequent calls to this method with the same key will override the previous value.
379    ///
380    /// # Example
381    /// ```
382    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
383    /// # use google_cloud_auth::signer::Signer;
384    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
385    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
386    ///         .with_query_param("generation", "1234567890")
387    ///         .sign_with(signer)
388    ///         .await?;
389    /// # Ok(())
390    /// }
391    /// ```
392    pub fn with_query_param<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
393        self.query_parameters.insert(key.into(), value.into());
394        self
395    }
396
397    /// Sets the endpoint for the signed URL. The default is `"https://storage.googleapis.com"`.
398    ///
399    /// This is useful when using a custom domain, or when testing with some Cloud Storage emulators.
400    ///
401    /// # Example
402    ///
403    /// ```
404    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
405    /// # use google_cloud_auth::signer::Signer;
406    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
407    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
408    ///         .with_endpoint("https://private.googleapis.com")
409    ///         .sign_with(signer)
410    ///         .await?;
411    /// # Ok(())
412    /// }
413    /// ```
414    pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
415        self.endpoint = Some(endpoint.into());
416        self
417    }
418
419    /// Configure the universe domain.
420    ///
421    /// The universe domain is the default service domain for a given cloud universe.
422    /// The default value is "googleapis.com".
423    ///
424    /// # Example
425    ///
426    /// ```
427    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
428    /// # use google_cloud_auth::signer::Signer;
429    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
430    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
431    ///         .with_universe_domain("googleapis.com")
432    ///         .sign_with(signer)
433    ///         .await?;
434    /// # Ok(())
435    /// }
436    /// ```
437    pub fn with_universe_domain<V: Into<String>>(mut self, v: V) -> Self {
438        self.universe_domain = Some(v.into());
439        self
440    }
441
442    /// Sets the client email for the signed URL.
443    ///
444    /// If not set, the email will be fetched from the signer.
445    ///
446    /// # Example
447    ///
448    /// ```
449    /// # use google_cloud_storage::builder::storage::SignedUrlBuilder;
450    /// # use google_cloud_auth::signer::Signer;
451    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
452    ///     let url = SignedUrlBuilder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
453    ///         .with_client_email("my-service-account@my-project.iam.gserviceaccount.com")
454    ///         .sign_with(signer)
455    ///         .await?;
456    /// # Ok(())
457    /// }
458    /// ```
459    pub fn with_client_email<S: Into<String>>(mut self, client_email: S) -> Self {
460        self.client_email = Some(client_email.into());
461        self
462    }
463
464    fn resolve_endpoint_url(&self) -> Result<SignedUrlEndpoint, SigningError> {
465        let endpoint = self.resolve_endpoint();
466        let url = url::Url::parse(&endpoint)
467            .map_err(|e| SigningError::invalid_parameter("endpoint", e))?;
468        let host = url.host_str().ok_or_else(|| {
469            SigningError::invalid_parameter("endpoint", "Invalid endpoint, missing host.")
470        })?;
471
472        // Extract host and port exactly as they appear in the endpoint.
473        // We do this because the url crate omits default ports (80/443),
474        // but GCS requires them to be maintained if explicitly provided.
475        let path = url.path();
476        let scheme = format!("{}://", url.scheme());
477        let host_with_port = endpoint.trim_start_matches(&scheme).trim_end_matches(path);
478
479        Ok(SignedUrlEndpoint {
480            scheme: url.scheme().to_string(),
481            host_with_port: host_with_port.to_string(),
482            host: host.to_string(),
483        })
484    }
485
486    fn resolve_endpoint(&self) -> String {
487        match self.endpoint.as_ref() {
488            Some(e) if e.starts_with("http://") => e.clone(),
489            Some(e) if e.starts_with("https://") => e.clone(),
490            Some(e) => format!("https://{}", e),
491            None => {
492                let universe_domain = self.universe_domain.as_deref().unwrap_or("googleapis.com");
493                format!("https://storage.{universe_domain}")
494            }
495        }
496    }
497
498    fn canonicalize_header_value(value: &str) -> String {
499        let clean_value = value.replace("\t", " ").trim().to_string();
500        clean_value.split_whitespace().collect::<Vec<_>>().join(" ")
501    }
502
503    /// Generates the signed URL using the provided signer.
504    /// Returns the signed URL, the string to sign, and the canonical request.
505    /// Used to check conformance test expectations.
506    async fn sign_internal(
507        self,
508        signer: &Signer,
509    ) -> std::result::Result<SigningComponents, SigningError> {
510        // Validate the bucket name.
511        self.scope.check_bucket_name()?;
512
513        let now = self.timestamp;
514        let request_timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
515        let datestamp = now.format("%Y%m%d");
516        let credential_scope = format!("{datestamp}/auto/storage/goog4_request");
517        let client_email = if let Some(email) = self.client_email.clone() {
518            email
519        } else {
520            signer.client_email().await.map_err(SigningError::signing)?
521        };
522        let credential = format!("{client_email}/{credential_scope}");
523
524        let endpoint = self.resolve_endpoint_url()?;
525        let canonical_url = endpoint.canonical_url(&self.scope, self.url_style);
526        let canonical_host = endpoint.canonical_host(&self.scope, self.url_style);
527
528        let mut headers = self.headers;
529        headers.insert("host".to_string(), canonical_host);
530
531        let header_keys = headers.keys().cloned().collect::<Vec<_>>();
532        let signed_headers = header_keys.join(";");
533
534        let mut query_parameters = self.query_parameters;
535        query_parameters.insert(
536            "X-Goog-Algorithm".to_string(),
537            "GOOG4-RSA-SHA256".to_string(),
538        );
539        query_parameters.insert("X-Goog-Credential".to_string(), credential);
540        query_parameters.insert("X-Goog-Date".to_string(), request_timestamp.clone());
541        query_parameters.insert(
542            "X-Goog-Expires".to_string(),
543            self.expiration.as_secs().to_string(),
544        );
545        query_parameters.insert("X-Goog-SignedHeaders".to_string(), signed_headers.clone());
546
547        let canonical_query = {
548            let mut canonical_query = url::form_urlencoded::Serializer::new("".to_string());
549            for (k, v) in &query_parameters {
550                canonical_query.append_pair(k, v);
551            }
552
553            canonical_query
554                .finish()
555                .replace("%7E", "~") // rollback to ~
556                .replace("+", "%20") // missing %20 in +
557        };
558
559        let canonical_headers = headers.iter().fold("".to_string(), |acc, (k, v)| {
560            let header_value = Self::canonicalize_header_value(v);
561            format!("{acc}{}:{}\n", k, header_value)
562        });
563
564        // If the user provides a value for X-Goog-Content-SHA256, we must use
565        // that value in the request string. If not, we use UNSIGNED-PAYLOAD.
566        let signature = headers
567            .get("x-goog-content-sha256")
568            .cloned()
569            .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string());
570
571        let canonical_uri = self.scope.canonical_uri(self.url_style);
572        let canonical_request = [
573            self.method.to_string(),
574            canonical_uri.clone(),
575            canonical_query.clone(),
576            canonical_headers,
577            signed_headers,
578            signature,
579        ]
580        .join("\n");
581
582        let canonical_request_hash = Sha256::digest(canonical_request.as_bytes());
583        let canonical_request_hash = hex::encode(canonical_request_hash);
584
585        let string_to_sign = [
586            "GOOG4-RSA-SHA256".to_string(),
587            request_timestamp,
588            credential_scope,
589            canonical_request_hash,
590        ]
591        .join("\n");
592
593        let signature = signer
594            .sign(string_to_sign.as_str())
595            .await
596            .map_err(SigningError::signing)?;
597
598        let signature = hex::encode(signature);
599
600        let signed_url = format!(
601            "{}?{}&X-Goog-Signature={}",
602            canonical_url, canonical_query, signature
603        );
604
605        Ok(SigningComponents {
606            #[cfg(test)]
607            canonical_request,
608            #[cfg(test)]
609            string_to_sign,
610            signed_url,
611        })
612    }
613
614    /// Generates the signed URL using the provided signer.
615    ///
616    /// # Returns
617    ///
618    /// A `Result` containing the signed URL as a `String` or a `SigningError`.
619    pub async fn sign_with(self, signer: &Signer) -> std::result::Result<String, SigningError> {
620        let components = self.sign_internal(signer).await?;
621        Ok(components.signed_url)
622    }
623}
624
625/// The resolved endpoint for a signed URL.
626struct SignedUrlEndpoint {
627    scheme: String,
628    host: String,
629    host_with_port: String,
630}
631
632impl SignedUrlEndpoint {
633    fn canonical_url(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
634        scope.canonical_url(&self.scheme, &self.host_with_port, url_style)
635    }
636
637    fn canonical_host(&self, scope: &SigningScope, url_style: UrlStyle) -> String {
638        scope.bucket_host(&self.host, url_style)
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use chrono::DateTime;
646    use google_cloud_auth::credentials::service_account::Builder as ServiceAccount;
647    use google_cloud_auth::signer::{Result as SignResult, Signer, SigningError, SigningProvider};
648    use serde::Deserialize;
649    use std::collections::HashMap;
650    use tokio::time::Duration;
651
652    type TestResult = anyhow::Result<()>;
653
654    mockall::mock! {
655        #[derive(Debug)]
656        Signer {}
657
658        impl SigningProvider for Signer {
659            async fn client_email(&self) -> SignResult<String>;
660            async fn sign(&self, content: &[u8]) -> SignResult<bytes::Bytes>;
661        }
662    }
663
664    impl SignedUrlBuilder {
665        /// Sets the timestamp for the signed URL. Only used in tests.
666        fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
667            self.timestamp = timestamp;
668            self
669        }
670    }
671
672    #[tokio::test]
673    async fn test_signed_url_builder() -> TestResult {
674        let mut mock = MockSigner::new();
675        mock.expect_client_email()
676            .return_once(|| Ok("test@example.com".to_string()));
677        mock.expect_sign()
678            .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
679
680        let signer = Signer::from(mock);
681        let _ = SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", "test-object")
682            .with_method(http::Method::PUT)
683            .with_expiration(Duration::from_secs(3600))
684            .with_header("x-goog-meta-test", "value")
685            .with_query_param("test", "value")
686            .with_endpoint("https://storage.googleapis.com")
687            .with_client_email("test@example.com")
688            .with_url_style(UrlStyle::PathStyle)
689            .sign_with(&signer)
690            .await?;
691
692        Ok(())
693    }
694
695    #[tokio::test]
696    async fn test_signed_url_error_signing() -> TestResult {
697        let mut mock = MockSigner::new();
698        mock.expect_client_email()
699            .return_once(|| Ok("test@example.com".to_string()));
700        mock.expect_sign()
701            .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
702
703        let signer = Signer::from(mock);
704        let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
705            .sign_with(&signer)
706            .await
707            .unwrap_err();
708
709        assert!(err.is_signing());
710
711        Ok(())
712    }
713
714    #[tokio::test]
715    async fn test_signed_url_error_endpoint() -> TestResult {
716        let mut mock = MockSigner::new();
717        mock.expect_client_email()
718            .return_once(|| Ok("test@example.com".to_string()));
719        mock.expect_sign()
720            .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
721
722        let signer = Signer::from(mock);
723        let err = SignedUrlBuilder::for_object("projects/_/buckets/b", "o")
724            .with_endpoint("invalid url")
725            .sign_with(&signer)
726            .await
727            .unwrap_err();
728
729        assert!(err.is_invalid_parameter());
730        assert!(err.to_string().contains("invalid `endpoint` parameter"));
731
732        Ok(())
733    }
734
735    #[tokio::test]
736    async fn test_signed_url_error_bucket() -> TestResult {
737        let mut mock = MockSigner::new();
738        mock.expect_client_email()
739            .return_once(|| Ok("test@example.com".to_string()));
740        mock.expect_sign()
741            .return_once(|_content| Ok(bytes::Bytes::from("test-signature")));
742
743        let signer = Signer::from(mock);
744        let err = SignedUrlBuilder::for_object("invalid-bucket-name", "o")
745            .sign_with(&signer)
746            .await
747            .unwrap_err();
748
749        assert!(err.is_invalid_parameter());
750        assert!(err.to_string().contains("malformed bucket name"));
751
752        Ok(())
753    }
754
755    #[tokio::test]
756    async fn sign_with_is_send() -> TestResult {
757        fn assert_send<T: Send>(_t: &T) {}
758
759        let mut mock = MockSigner::new();
760        mock.expect_client_email()
761            .return_once(|| Ok("test@example.com".to_string()));
762        mock.expect_sign()
763            .return_once(|_content| Err(SigningError::from_msg("test".to_string())));
764
765        let signer = Signer::from(mock);
766        let fut = SignedUrlBuilder::for_object("projects/_/buckets/b", "o").sign_with(&signer);
767
768        assert_send(&fut);
769
770        Ok(())
771    }
772
773    #[test_case::test_case(
774        Some("path/with/slashes/under_score/amper&sand/file.ext"),
775        None,
776        UrlStyle::PathStyle,
777        "https://storage.googleapis.com/test-bucket/path/with/slashes/under_score/amper%26sand/file.ext"
778    ; "escape object name")]
779    #[test_case::test_case(
780        Some("folder/test object.txt"),
781        None,
782        UrlStyle::PathStyle,
783        "https://storage.googleapis.com/test-bucket/folder/test%20object.txt"
784    ; "escape object name with spaces")]
785    #[test_case::test_case(
786        Some("test-object"),
787        None,
788        UrlStyle::VirtualHostedStyle,
789        "https://test-bucket.storage.googleapis.com/test-object"
790    ; "virtual hosted style")]
791    #[test_case::test_case(
792        Some("test-object"),
793        Some("http://mydomain.tld"),
794        UrlStyle::BucketBoundHostname,
795        "http://mydomain.tld/test-object"
796    ; "bucket bound style")]
797    #[test_case::test_case(
798        None,
799        None,
800        UrlStyle::PathStyle,
801        "https://storage.googleapis.com/test-bucket"
802    ; "list objects")]
803    fn test_signed_url_canonical_url(
804        object: Option<&str>,
805        endpoint: Option<&str>,
806        url_style: UrlStyle,
807        expected_url: &str,
808    ) -> TestResult {
809        let builder = if let Some(object) = object {
810            SignedUrlBuilder::for_object("projects/_/buckets/test-bucket", object)
811        } else {
812            SignedUrlBuilder::for_bucket("projects/_/buckets/test-bucket")
813        };
814        let builder = builder.with_url_style(url_style);
815        let builder = endpoint.iter().fold(builder, |builder, endpoint| {
816            builder.with_endpoint(*endpoint)
817        });
818
819        let endpoint = builder.resolve_endpoint_url()?;
820        let url = endpoint.canonical_url(&builder.scope, builder.url_style);
821        assert_eq!(url, expected_url);
822
823        Ok(())
824    }
825
826    #[derive(Deserialize)]
827    #[serde(rename_all = "camelCase")]
828    struct SignedUrlTestSuite {
829        signing_v4_tests: Vec<SignedUrlTest>,
830    }
831
832    #[derive(Deserialize)]
833    #[serde(rename_all = "camelCase")]
834    struct SignedUrlTest {
835        description: String,
836        bucket: String,
837        object: Option<String>,
838        method: String,
839        expiration: u64,
840        timestamp: String,
841        expected_url: String,
842        headers: Option<HashMap<String, String>>,
843        query_parameters: Option<HashMap<String, String>>,
844        scheme: Option<String>,
845        url_style: Option<String>,
846        bucket_bound_hostname: Option<String>,
847        expected_canonical_request: String,
848        expected_string_to_sign: String,
849        hostname: Option<String>,
850        client_endpoint: Option<String>,
851        emulator_hostname: Option<String>,
852        universe_domain: Option<String>,
853    }
854
855    #[tokio::test]
856    async fn signed_url_conformance() -> anyhow::Result<()> {
857        let service_account_key = serde_json::from_slice(include_bytes!(
858            "conformance/test_service_account.not-a-test.json"
859        ))?;
860
861        let signer = ServiceAccount::new(service_account_key)
862            .build_signer()
863            .expect("failed to build signer");
864
865        let suite: SignedUrlTestSuite =
866            serde_json::from_slice(include_bytes!("conformance/v4_signatures.json"))?;
867
868        let mut failed_tests = Vec::new();
869        let mut skipped_tests = Vec::new();
870        let mut passed_tests = Vec::new();
871        let total_tests = suite.signing_v4_tests.len();
872        for test in suite.signing_v4_tests {
873            let timestamp =
874                DateTime::parse_from_rfc3339(&test.timestamp).expect("invalid timestamp");
875            let method = http::Method::from_bytes(test.method.as_bytes()).expect("invalid method");
876            let scheme = test.scheme.unwrap_or("https".to_string());
877            let url_style = match test.url_style {
878                Some(url_style) => match url_style.as_str() {
879                    "VIRTUAL_HOSTED_STYLE" => UrlStyle::VirtualHostedStyle,
880                    "BUCKET_BOUND_HOSTNAME" => UrlStyle::BucketBoundHostname,
881                    _ => UrlStyle::PathStyle,
882                },
883                None => UrlStyle::PathStyle,
884            };
885
886            let bucket = format!("projects/_/buckets/{}", test.bucket);
887            let builder = match test.object {
888                Some(object) => SignedUrlBuilder::for_object(bucket, object),
889                None => SignedUrlBuilder::for_bucket(bucket),
890            };
891
892            if test.emulator_hostname.is_some() {
893                skipped_tests.push(test.description);
894                continue;
895            }
896
897            let builder = builder
898                .with_method(method)
899                .with_url_style(url_style)
900                .with_expiration(Duration::from_secs(test.expiration))
901                .with_timestamp(timestamp.into());
902
903            let builder = test
904                .universe_domain
905                .iter()
906                .fold(builder, |builder, universe_domain| {
907                    builder.with_universe_domain(universe_domain)
908                });
909            let builder = test
910                .client_endpoint
911                .iter()
912                .fold(builder, |builder, client_endpoint| {
913                    builder.with_endpoint(client_endpoint)
914                });
915            let builder = test
916                .bucket_bound_hostname
917                .iter()
918                .fold(builder, |builder, hostname| {
919                    builder.with_endpoint(format!("{}://{}", scheme, hostname))
920                });
921            let builder = test.hostname.iter().fold(builder, |builder, hostname| {
922                builder.with_endpoint(format!("{}://{}", scheme, hostname))
923            });
924            let builder = test.headers.iter().fold(builder, |builder, headers| {
925                headers.iter().fold(builder, |builder, (k, v)| {
926                    builder.with_header(k.clone(), v.clone())
927                })
928            });
929            let builder = test
930                .query_parameters
931                .iter()
932                .fold(builder, |builder, query_params| {
933                    query_params.iter().fold(builder, |builder, (k, v)| {
934                        builder.with_query_param(k.clone(), v.clone())
935                    })
936                });
937
938            let components = builder.sign_internal(&signer).await;
939            let components = match components {
940                Ok(components) => components,
941                Err(e) => {
942                    println!("❌ Failed test: {}", test.description);
943                    println!("Error: {}", e);
944                    failed_tests.push(test.description);
945                    continue;
946                }
947            };
948
949            let canonical_request = components.canonical_request;
950            let string_to_sign = components.string_to_sign;
951            let signed_url = components.signed_url;
952
953            if canonical_request != test.expected_canonical_request
954                || string_to_sign != test.expected_string_to_sign
955                || signed_url != test.expected_url
956            {
957                println!("❌ Failed test: {}", test.description);
958                let diff = pretty_assertions::StrComparison::new(
959                    &canonical_request,
960                    &test.expected_canonical_request,
961                );
962                println!("Canonical request diff: {}", diff);
963                let diff = pretty_assertions::StrComparison::new(
964                    &string_to_sign,
965                    &test.expected_string_to_sign,
966                );
967                println!("String to sign diff: {}", diff);
968                let diff = pretty_assertions::StrComparison::new(&signed_url, &test.expected_url);
969                println!("Signed URL diff: {}", diff);
970                failed_tests.push(test.description);
971                continue;
972            }
973            passed_tests.push(test.description);
974        }
975
976        let failed = !failed_tests.is_empty();
977        let total_passed = passed_tests.len();
978        for test in passed_tests {
979            println!("✅ Passed test: {}", test);
980        }
981        for test in skipped_tests {
982            println!("🟡 Skipped test: {}", test);
983        }
984        for test in failed_tests {
985            println!("❌ Failed test: {}", test);
986        }
987        println!("{}/{} tests passed", total_passed, total_tests);
988
989        if failed {
990            anyhow::bail!("Some tests failed")
991        }
992        Ok(())
993    }
994}