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