google_cloud_storage/
client.rs

1use std::ops::Deref;
2
3use ring::{rand, signature};
4
5use google_cloud_token::{NopeTokenSourceProvider, TokenSourceProvider};
6
7use crate::http::service_account_client::ServiceAccountClient;
8use crate::http::storage_client::StorageClient;
9use crate::sign::SignBy::PrivateKey;
10use crate::sign::{create_signed_buffer, RsaKeyPair, SignBy, SignedURLError, SignedURLOptions};
11
12///
13/// #### Example building a client configuration with a custom retry strategy as middleware:
14/// ```rust
15/// #   use google_cloud_storage::client::Client;
16/// #   use google_cloud_storage::client::ClientConfig;
17/// #   use reqwest_middleware::ClientBuilder;
18/// #   use reqwest_retry::policies::ExponentialBackoff;
19/// #   use reqwest_retry::RetryTransientMiddleware;
20/// #   use retry_policies::Jitter;
21///
22/// async fn configuration_with_exponential_backoff_retry_strategy() -> ClientConfig {
23///   let retry_policy = ExponentialBackoff::builder()
24///      .base(2)
25///      .jitter(Jitter::Full)
26///      .build_with_max_retries(3);
27///
28///   let mid_client = ClientBuilder::new(reqwest::Client::default())
29///      // reqwest-retry already comes with a default retry stategy that matches http standards
30///      // override it only if you need a custom one due to non standard behaviour
31///      .with(RetryTransientMiddleware::new_with_policy(retry_policy))
32///      .build();
33///
34///   ClientConfig {
35///      http: Some(mid_client),
36///      ..Default::default()
37///   }
38/// }
39///
40/// ```
41#[derive(Debug)]
42pub struct ClientConfig {
43    pub http: Option<reqwest_middleware::ClientWithMiddleware>,
44    pub storage_endpoint: String,
45    pub service_account_endpoint: String,
46    pub token_source_provider: Option<Box<dyn TokenSourceProvider>>,
47    pub default_google_access_id: Option<String>,
48    pub default_sign_by: Option<SignBy>,
49    pub project_id: Option<String>,
50}
51
52impl Default for ClientConfig {
53    fn default() -> Self {
54        Self {
55            http: None,
56            storage_endpoint: "https://storage.googleapis.com".to_string(),
57            token_source_provider: Some(Box::new(NopeTokenSourceProvider {})),
58            service_account_endpoint: "https://iamcredentials.googleapis.com".to_string(),
59            default_google_access_id: None,
60            default_sign_by: None,
61            project_id: None,
62        }
63    }
64}
65
66impl ClientConfig {
67    pub fn anonymous(mut self) -> Self {
68        self.token_source_provider = None;
69        self
70    }
71}
72
73#[cfg(feature = "auth")]
74pub use google_cloud_auth;
75
76#[cfg(feature = "auth")]
77impl ClientConfig {
78    pub async fn with_auth(self) -> Result<Self, google_cloud_auth::error::Error> {
79        let ts = google_cloud_auth::token::DefaultTokenSourceProvider::new(Self::auth_config()).await?;
80        Ok(self.with_token_source(ts).await)
81    }
82
83    pub async fn with_credentials(
84        self,
85        credentials: google_cloud_auth::credentials::CredentialsFile,
86    ) -> Result<Self, google_cloud_auth::error::Error> {
87        let ts = google_cloud_auth::token::DefaultTokenSourceProvider::new_with_credentials(
88            Self::auth_config(),
89            Box::new(credentials),
90        )
91        .await?;
92        Ok(self.with_token_source(ts).await)
93    }
94
95    async fn with_token_source(mut self, ts: google_cloud_auth::token::DefaultTokenSourceProvider) -> Self {
96        match &ts.source_credentials {
97            // Credential file is used.
98            Some(cred) => {
99                self.project_id.clone_from(&cred.project_id);
100                if let Some(pk) = &cred.private_key {
101                    self.default_sign_by = Some(PrivateKey(pk.clone().into_bytes()));
102                }
103                self.default_google_access_id.clone_from(&cred.client_email);
104            }
105            // On Google Cloud
106            None => {
107                self.project_id = Some(google_cloud_metadata::project_id().await);
108                self.default_sign_by = Some(SignBy::SignBytes);
109                self.default_google_access_id = google_cloud_metadata::email("default").await.ok();
110            }
111        }
112        self.token_source_provider = Some(Box::new(ts));
113        self
114    }
115
116    fn auth_config() -> google_cloud_auth::project::Config<'static> {
117        google_cloud_auth::project::Config::default().with_scopes(&crate::http::storage_client::SCOPES)
118    }
119}
120
121#[derive(Clone)]
122pub struct Client {
123    default_google_access_id: Option<String>,
124    default_sign_by: Option<SignBy>,
125    storage_client: StorageClient,
126    service_account_client: ServiceAccountClient,
127}
128
129impl Deref for Client {
130    type Target = StorageClient;
131
132    fn deref(&self) -> &Self::Target {
133        &self.storage_client
134    }
135}
136
137impl Client {
138    /// New client
139    pub fn new(config: ClientConfig) -> Self {
140        let ts = match config.token_source_provider {
141            Some(tsp) => Some(tsp.token_source()),
142            None => {
143                tracing::trace!("Use anonymous access due to lack of token");
144                None
145            }
146        };
147        let http = config
148            .http
149            .unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::default()).build());
150
151        let service_account_client =
152            ServiceAccountClient::new(ts.clone(), config.service_account_endpoint.as_str(), http.clone());
153        let storage_client = StorageClient::new(ts, config.storage_endpoint.as_str(), http);
154
155        Self {
156            default_google_access_id: config.default_google_access_id,
157            default_sign_by: config.default_sign_by,
158            storage_client,
159            service_account_client,
160        }
161    }
162
163    /// Get signed url.
164    /// SignedURL returns a URL for the specified object. Signed URLs allow anyone
165    /// access to a restricted resource for a limited time without needing a
166    /// Google account or signing in. For more information about signed URLs, see
167    /// https://cloud.google.com/storage/docs/accesscontrol#signed_urls_query_string_authentication
168    ///
169    /// Using the client defaults:
170    /// ```
171    /// use google_cloud_storage::client::Client;
172    /// use google_cloud_storage::sign::{SignedURLOptions, SignedURLMethod};
173    ///
174    /// async fn run(client: Client) {
175    ///     let url_for_download = client.signed_url("bucket", "file.txt", None, None, SignedURLOptions::default()).await;
176    ///     let url_for_upload = client.signed_url("bucket", "file.txt", None, None, SignedURLOptions {
177    ///         method: SignedURLMethod::PUT,
178    ///         ..Default::default()
179    ///     }).await;
180    /// }
181    /// ```
182    ///
183    /// Overwriting the client defaults:
184    /// ```
185    /// use google_cloud_storage::client::Client;
186    /// use google_cloud_storage::sign::{SignBy, SignedURLOptions, SignedURLMethod};
187    ///
188    /// async fn run(client: Client) {
189    /// #   let private_key = SignBy::PrivateKey(vec![]);
190    ///
191    ///     let url_for_download = client.signed_url("bucket", "file.txt", Some("google_access_id".to_string()), Some(private_key.clone()), SignedURLOptions::default()).await;
192    ///     let url_for_upload = client.signed_url("bucket", "file.txt", Some("google_access_id".to_string()), Some(private_key.clone()), SignedURLOptions {
193    ///         method: SignedURLMethod::PUT,
194    ///         ..Default::default()
195    ///     }).await;
196    /// }
197    /// ```
198    #[cfg_attr(feature = "trace", tracing::instrument(skip_all))]
199    pub async fn signed_url(
200        &self,
201        bucket: &str,
202        object: &str,
203        google_access_id: Option<String>,
204        sign_by: Option<SignBy>,
205        opts: SignedURLOptions,
206    ) -> Result<String, SignedURLError> {
207        // use the one from the options or the default one or error out
208
209        let google_access_id = match &google_access_id {
210            Some(overwritten_gai) => overwritten_gai.to_owned(),
211            None => {
212                let default_gai = &self
213                    .default_google_access_id
214                    .clone()
215                    .ok_or(SignedURLError::InvalidOption("No default google_access_id is found"))?;
216
217                default_gai.to_owned()
218            }
219        };
220
221        // use the one from the options or the default one or error out
222        let sign_by = match &sign_by {
223            Some(overwritten_sign_by) => overwritten_sign_by.to_owned(),
224            None => {
225                let default_sign_by = &self
226                    .default_sign_by
227                    .clone()
228                    .ok_or(SignedURLError::InvalidOption("No default sign_by is found"))?;
229
230                default_sign_by.to_owned()
231            }
232        };
233
234        let (signed_buffer, mut builder) = create_signed_buffer(bucket, object, &google_access_id, &opts)?;
235        tracing::trace!("signed_buffer={:?}", String::from_utf8_lossy(&signed_buffer));
236
237        // create signature
238        let signature = match &sign_by {
239            PrivateKey(private_key) => {
240                // if sign_by is a collection of private keys we check that at least one is present
241                if private_key.is_empty() {
242                    return Err(SignedURLError::InvalidOption("No keys present"));
243                }
244                let key_pair = &RsaKeyPair::try_from(private_key)?;
245                let mut signed = vec![0; key_pair.public().modulus_len()];
246                key_pair
247                    .sign(
248                        &signature::RSA_PKCS1_SHA256,
249                        &rand::SystemRandom::new(),
250                        signed_buffer.as_slice(),
251                        &mut signed,
252                    )
253                    .map_err(|e| SignedURLError::CertError(e.to_string()))?;
254                signed
255            }
256            SignBy::SignBytes => {
257                let path = format!("projects/-/serviceAccounts/{}", google_access_id);
258                self.service_account_client
259                    .sign_blob(&path, signed_buffer.as_slice())
260                    .await
261                    .map_err(SignedURLError::SignBlob)?
262            }
263        };
264        builder
265            .query_pairs_mut()
266            .append_pair("X-Goog-Signature", &hex::encode(signature));
267        Ok(builder.to_string())
268    }
269}
270
271#[cfg(test)]
272mod test {
273
274    use serial_test::serial;
275
276    use crate::client::{Client, ClientConfig};
277    use crate::http::buckets::get::GetBucketRequest;
278
279    use crate::http::storage_client::test::bucket_name;
280    use crate::sign::{SignedURLMethod, SignedURLOptions};
281
282    async fn create_client() -> (Client, String) {
283        let config = ClientConfig::default().with_auth().await.unwrap();
284        let project_id = config.project_id.clone();
285        (Client::new(config), project_id.unwrap())
286    }
287
288    #[tokio::test]
289    #[serial]
290    async fn test_sign() {
291        let (client, project) = create_client().await;
292        let bucket_name = bucket_name(&project, "object");
293        let data = "aiueo";
294        let content_type = "application/octet-stream";
295
296        // upload
297        let option = SignedURLOptions {
298            method: SignedURLMethod::PUT,
299            content_type: Some(content_type.to_string()),
300            ..SignedURLOptions::default()
301        };
302        let url = client
303            .signed_url(&bucket_name, "signed_uploadtest", None, None, option)
304            .await
305            .unwrap();
306        println!("uploading={url:?}");
307        let request = reqwest::Client::default()
308            .put(url)
309            .header("content-type", content_type)
310            .body(data.as_bytes());
311        let result = request.send().await.unwrap();
312        let status = result.status();
313        assert!(status.is_success(), "{:?}", result.text().await.unwrap());
314
315        //download
316        let option = SignedURLOptions {
317            content_type: Some(content_type.to_string()),
318            ..SignedURLOptions::default()
319        };
320        let url = client
321            .signed_url(&bucket_name, "signed_uploadtest", None, None, option)
322            .await
323            .unwrap();
324        println!("downloading={url:?}");
325        let result = reqwest::Client::default()
326            .get(url)
327            .header("content-type", content_type)
328            .send()
329            .await
330            .unwrap()
331            .text()
332            .await
333            .unwrap();
334        assert_eq!(result, data);
335    }
336
337    #[tokio::test]
338    #[serial]
339    async fn test_sign_with_overwrites() {
340        let (client, project) = create_client().await;
341        let bucket_name = bucket_name(&project, "object");
342        let data = "aiueo";
343        let content_type = "application/octet-stream";
344        let overwritten_gai = client.default_google_access_id.as_ref().unwrap();
345        let overwritten_sign_by = client.default_sign_by.as_ref().unwrap();
346
347        // upload
348        let option = SignedURLOptions {
349            method: SignedURLMethod::PUT,
350            content_type: Some(content_type.to_string()),
351            ..SignedURLOptions::default()
352        };
353        let url = client
354            .signed_url(
355                &bucket_name,
356                "signed_uploadtest",
357                Some(overwritten_gai.to_owned()),
358                Some(overwritten_sign_by.to_owned()),
359                option,
360            )
361            .await
362            .unwrap();
363        println!("uploading={url:?}");
364        let request = reqwest::Client::default()
365            .put(url)
366            .header("content-type", content_type)
367            .body(data.as_bytes());
368        let result = request.send().await.unwrap();
369        let status = result.status();
370        assert!(status.is_success(), "{:?}", result.text().await.unwrap());
371
372        //download
373        let option = SignedURLOptions {
374            content_type: Some(content_type.to_string()),
375            ..SignedURLOptions::default()
376        };
377
378        let url = client
379            .signed_url(
380                &bucket_name,
381                "signed_uploadtest",
382                Some(overwritten_gai.to_owned()),
383                Some(overwritten_sign_by.to_owned()),
384                option,
385            )
386            .await
387            .unwrap();
388        println!("downloading={url:?}");
389        let result = reqwest::Client::default()
390            .get(url)
391            .header("content-type", content_type)
392            .send()
393            .await
394            .unwrap()
395            .text()
396            .await
397            .unwrap();
398        assert_eq!(result, data);
399    }
400
401    #[tokio::test]
402    #[serial]
403    async fn test_anonymous() {
404        let project = ClientConfig::default().with_auth().await.unwrap().project_id.unwrap();
405        let bucket = bucket_name(&project, "anonymous");
406
407        let config = ClientConfig::default().anonymous();
408        let client = Client::new(config);
409        let result = client
410            .get_bucket(&GetBucketRequest {
411                bucket: bucket.clone(),
412                ..Default::default()
413            })
414            .await
415            .unwrap();
416        assert_eq!(result.name, bucket);
417    }
418}