gcloud_storage_patch/
client.rs

1use std::ops::Deref;
2
3use ring::{rand, signature};
4
5use token_source::{NoopTokenSourceProvider, 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(NoopTokenSourceProvider {})),
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()
118            .with_audience(&crate::http::storage_client::AUDIENCE)
119            .with_scopes(&crate::http::storage_client::SCOPES)
120    }
121}
122
123#[derive(Clone)]
124pub struct Client {
125    default_google_access_id: Option<String>,
126    default_sign_by: Option<SignBy>,
127    storage_client: StorageClient,
128    service_account_client: ServiceAccountClient,
129}
130
131impl Deref for Client {
132    type Target = StorageClient;
133
134    fn deref(&self) -> &Self::Target {
135        &self.storage_client
136    }
137}
138
139impl Client {
140    /// New client
141    pub fn new(config: ClientConfig) -> Self {
142        let ts = match config.token_source_provider {
143            Some(tsp) => Some(tsp.token_source()),
144            None => {
145                tracing::trace!("Use anonymous access due to lack of token");
146                None
147            }
148        };
149        let http = config
150            .http
151            .unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::default()).build());
152
153        let service_account_client =
154            ServiceAccountClient::new(ts.clone(), config.service_account_endpoint.as_str(), http.clone());
155        let storage_client = StorageClient::new(ts, config.storage_endpoint.as_str(), http);
156
157        Self {
158            default_google_access_id: config.default_google_access_id,
159            default_sign_by: config.default_sign_by,
160            storage_client,
161            service_account_client,
162        }
163    }
164
165    /// Get signed url.
166    /// SignedURL returns a URL for the specified object. Signed URLs allow anyone
167    /// access to a restricted resource for a limited time without needing a
168    /// Google account or signing in. For more information about signed URLs, see
169    /// https://cloud.google.com/storage/docs/accesscontrol#signed_urls_query_string_authentication
170    ///
171    /// Using the client defaults:
172    /// ```
173    /// use google_cloud_storage::client::Client;
174    /// use google_cloud_storage::sign::{SignedURLOptions, SignedURLMethod};
175    ///
176    /// async fn run(client: Client) {
177    ///     let url_for_download = client.signed_url("bucket", "file.txt", None, None, SignedURLOptions::default()).await;
178    ///     let url_for_upload = client.signed_url("bucket", "file.txt", None, None, SignedURLOptions {
179    ///         method: SignedURLMethod::PUT,
180    ///         ..Default::default()
181    ///     }).await;
182    /// }
183    /// ```
184    ///
185    /// Overwriting the client defaults:
186    /// ```
187    /// use google_cloud_storage::client::Client;
188    /// use google_cloud_storage::sign::{SignBy, SignedURLOptions, SignedURLMethod};
189    ///
190    /// async fn run(client: Client) {
191    /// #   let private_key = SignBy::PrivateKey(vec![]);
192    ///
193    ///     let url_for_download = client.signed_url("bucket", "file.txt", Some("google_access_id".to_string()), Some(private_key.clone()), SignedURLOptions::default()).await;
194    ///     let url_for_upload = client.signed_url("bucket", "file.txt", Some("google_access_id".to_string()), Some(private_key.clone()), SignedURLOptions {
195    ///         method: SignedURLMethod::PUT,
196    ///         ..Default::default()
197    ///     }).await;
198    /// }
199    /// ```
200    #[cfg_attr(feature = "trace", tracing::instrument(skip_all))]
201    pub async fn signed_url(
202        &self,
203        bucket: &str,
204        object: &str,
205        google_access_id: Option<String>,
206        sign_by: Option<SignBy>,
207        opts: SignedURLOptions,
208    ) -> Result<String, SignedURLError> {
209        // use the one from the options or the default one or error out
210
211        let google_access_id = match &google_access_id {
212            Some(overwritten_gai) => overwritten_gai.to_owned(),
213            None => {
214                let default_gai = &self
215                    .default_google_access_id
216                    .clone()
217                    .ok_or(SignedURLError::InvalidOption("No default google_access_id is found"))?;
218
219                default_gai.to_owned()
220            }
221        };
222
223        // use the one from the options or the default one or error out
224        let sign_by = match &sign_by {
225            Some(overwritten_sign_by) => overwritten_sign_by.to_owned(),
226            None => {
227                let default_sign_by = &self
228                    .default_sign_by
229                    .clone()
230                    .ok_or(SignedURLError::InvalidOption("No default sign_by is found"))?;
231
232                default_sign_by.to_owned()
233            }
234        };
235
236        let (signed_buffer, mut builder) = create_signed_buffer(bucket, object, &google_access_id, &opts)?;
237        tracing::trace!("signed_buffer={:?}", String::from_utf8_lossy(&signed_buffer));
238
239        // create signature
240        let signature = match &sign_by {
241            PrivateKey(private_key) => {
242                // if sign_by is a collection of private keys we check that at least one is present
243                if private_key.is_empty() {
244                    return Err(SignedURLError::InvalidOption("No keys present"));
245                }
246                let key_pair = &RsaKeyPair::try_from(private_key)?;
247                let mut signed = vec![0; key_pair.public().modulus_len()];
248                key_pair
249                    .sign(
250                        &signature::RSA_PKCS1_SHA256,
251                        &rand::SystemRandom::new(),
252                        signed_buffer.as_slice(),
253                        &mut signed,
254                    )
255                    .map_err(|e| SignedURLError::CertError(e.to_string()))?;
256                signed
257            }
258            SignBy::SignBytes => {
259                let path = format!("projects/-/serviceAccounts/{google_access_id}");
260                self.service_account_client
261                    .sign_blob(&path, signed_buffer.as_slice())
262                    .await
263                    .map_err(SignedURLError::SignBlob)?
264            }
265        };
266        builder
267            .query_pairs_mut()
268            .append_pair("X-Goog-Signature", &hex::encode(signature));
269        Ok(builder.to_string())
270    }
271}
272
273#[cfg(test)]
274mod test {
275
276    use serial_test::serial;
277
278    use crate::client::{Client, ClientConfig};
279    use crate::http::buckets::get::GetBucketRequest;
280
281    use crate::http::storage_client::test::bucket_name;
282    use crate::sign::{SignedURLMethod, SignedURLOptions};
283
284    async fn create_client() -> (Client, String) {
285        let config = ClientConfig::default().with_auth().await.unwrap();
286        let project_id = config.project_id.clone();
287        (Client::new(config), project_id.unwrap())
288    }
289
290    #[tokio::test]
291    #[serial]
292    async fn test_sign() {
293        let (client, project) = create_client().await;
294        let bucket_name = bucket_name(&project, "object");
295        let data = "aiueo";
296        let content_type = "application/octet-stream";
297
298        // upload
299        let option = SignedURLOptions {
300            method: SignedURLMethod::PUT,
301            content_type: Some(content_type.to_string()),
302            ..SignedURLOptions::default()
303        };
304        let url = client
305            .signed_url(&bucket_name, "signed_uploadtest", None, None, option)
306            .await
307            .unwrap();
308        println!("uploading={url:?}");
309        let request = reqwest::Client::default()
310            .put(url)
311            .header("content-type", content_type)
312            .body(data.as_bytes());
313        let result = request.send().await.unwrap();
314        let status = result.status();
315        assert!(status.is_success(), "{:?}", result.text().await.unwrap());
316
317        //download
318        let option = SignedURLOptions {
319            content_type: Some(content_type.to_string()),
320            ..SignedURLOptions::default()
321        };
322        let url = client
323            .signed_url(&bucket_name, "signed_uploadtest", None, None, option)
324            .await
325            .unwrap();
326        println!("downloading={url:?}");
327        let result = reqwest::Client::default()
328            .get(url)
329            .header("content-type", content_type)
330            .send()
331            .await
332            .unwrap()
333            .text()
334            .await
335            .unwrap();
336        assert_eq!(result, data);
337    }
338
339    #[tokio::test]
340    #[serial]
341    async fn test_sign_with_overwrites() {
342        let (client, project) = create_client().await;
343        let bucket_name = bucket_name(&project, "object");
344        let data = "aiueo";
345        let content_type = "application/octet-stream";
346        let overwritten_gai = client.default_google_access_id.as_ref().unwrap();
347        let overwritten_sign_by = client.default_sign_by.as_ref().unwrap();
348
349        // upload
350        let option = SignedURLOptions {
351            method: SignedURLMethod::PUT,
352            content_type: Some(content_type.to_string()),
353            ..SignedURLOptions::default()
354        };
355        let url = client
356            .signed_url(
357                &bucket_name,
358                "signed_uploadtest",
359                Some(overwritten_gai.to_owned()),
360                Some(overwritten_sign_by.to_owned()),
361                option,
362            )
363            .await
364            .unwrap();
365        println!("uploading={url:?}");
366        let request = reqwest::Client::default()
367            .put(url)
368            .header("content-type", content_type)
369            .body(data.as_bytes());
370        let result = request.send().await.unwrap();
371        let status = result.status();
372        assert!(status.is_success(), "{:?}", result.text().await.unwrap());
373
374        //download
375        let option = SignedURLOptions {
376            content_type: Some(content_type.to_string()),
377            ..SignedURLOptions::default()
378        };
379
380        let url = client
381            .signed_url(
382                &bucket_name,
383                "signed_uploadtest",
384                Some(overwritten_gai.to_owned()),
385                Some(overwritten_sign_by.to_owned()),
386                option,
387            )
388            .await
389            .unwrap();
390        println!("downloading={url:?}");
391        let result = reqwest::Client::default()
392            .get(url)
393            .header("content-type", content_type)
394            .send()
395            .await
396            .unwrap()
397            .text()
398            .await
399            .unwrap();
400        assert_eq!(result, data);
401    }
402
403    #[tokio::test]
404    #[serial]
405    async fn test_anonymous() {
406        let project = ClientConfig::default().with_auth().await.unwrap().project_id.unwrap();
407        let bucket = bucket_name(&project, "anonymous");
408
409        let config = ClientConfig::default().anonymous();
410        let client = Client::new(config);
411        let result = client
412            .get_bucket(&GetBucketRequest {
413                bucket: bucket.clone(),
414                ..Default::default()
415            })
416            .await
417            .unwrap();
418        assert_eq!(result.name, bucket);
419    }
420}