Skip to main content

alien_bindings/providers/storage/
gcp_gcs.rs

1use crate::providers::storage::credential_bridge::GcpCredentialBridge;
2use crate::providers::utils::{prefixed_path, relativize_path};
3use crate::{
4    error::{Error, ErrorData},
5    presigned::{PresignedOperation, PresignedRequest, PresignedRequestBackend},
6    traits::{Binding, Storage},
7};
8use alien_error::{AlienError, Context, IntoAlienError};
9use async_trait::async_trait;
10use bytes::Bytes;
11use chrono::Utc;
12use futures::stream::BoxStream;
13use futures::TryStreamExt as _;
14use object_store::signer::Signer;
15use object_store::{
16    gcp::GoogleCloudStorage, path::Path, Attribute, Attributes, GetOptions, GetResult, ListResult,
17    ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
18    Result as ObjectStoreResult,
19};
20use reqwest::Method;
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24use url::Url;
25
26/// Google Cloud Storage implementation.
27#[derive(Debug)]
28pub struct GcsStorage {
29    url: Url,
30    base_dir: Path,
31    inner: GoogleCloudStorage,
32}
33
34impl GcsStorage {
35    /// Creates a new `GcsStorage` instance from bucket configuration.
36    ///
37    /// Uses GCP config for credentials.
38    pub fn new(
39        bucket_name: String,
40        gcp_config: &alien_core::GcpClientConfig,
41    ) -> Result<Self, Error> {
42        let gcs_url = format!("gs://{}", bucket_name);
43        let url = Url::parse(&gcs_url).into_alien_error().context(
44            ErrorData::InvalidConfigurationUrl {
45                url: gcs_url.clone(),
46                reason: "Invalid GCS URL format".to_string(),
47            },
48        )?;
49
50        // Build the store with credentials bridged from GcpClientConfig.
51        // For presigned URLs, object_store needs signing credentials (private key), so
52        // provide the service account key directly when that credential mode is used.
53        let credentials = GcpCredentialBridge::new(gcp_config.clone());
54        let mut builder = object_store::gcp::GoogleCloudStorageBuilder::new()
55            .with_bucket_name(&bucket_name)
56            .with_credentials(Arc::new(credentials));
57
58        if let alien_core::GcpCredentials::ServiceAccountKey { json } = &gcp_config.credentials {
59            builder = builder.with_service_account_key(json);
60        }
61
62        let store = builder
63            .build()
64            .into_alien_error()
65            .context(ErrorData::BindingSetupFailed {
66                binding_type: "GCP GCS storage".to_string(),
67                reason: format!("Failed to build GCS client for bucket: {}", bucket_name),
68            })?;
69
70        // Extract the base path from the URL path segments, handling the None case.
71        let base_dir = match url.path_segments() {
72            Some(segments) => Path::from_iter(segments.filter(|s| !s.is_empty())),
73            None => Path::default(), // Use an empty path if there are no segments
74        };
75
76        Ok(Self {
77            url,
78            base_dir,
79            inner: store,
80        })
81    }
82}
83
84impl Binding for GcsStorage {}
85
86fn validate_put_attributes(attributes: &Attributes) -> ObjectStoreResult<()> {
87    let gzip_encoding = attributes
88        .get(&Attribute::ContentEncoding)
89        .is_some_and(|value| value.as_ref().eq_ignore_ascii_case("gzip"));
90    if gzip_encoding {
91        return Err(object_store::Error::Generic {
92            store: "GcsStorage",
93            source: Box::new(AlienError::new(ErrorData::OperationNotSupported {
94                operation: "storage.put contentEncoding=gzip".to_string(),
95                reason: "GCS decompressive transcoding is incompatible with object reads"
96                    .to_string(),
97            })),
98        });
99    }
100    Ok(())
101}
102
103#[async_trait]
104impl Storage for GcsStorage {
105    fn get_base_dir(&self) -> Path {
106        self.base_dir.clone()
107    }
108
109    fn get_url(&self) -> Url {
110        self.url.clone()
111    }
112
113    async fn presigned_put(
114        &self,
115        path: &Path,
116        expires_in: Duration,
117    ) -> crate::error::Result<PresignedRequest> {
118        // Note: Presigned URLs require signing credentials (private key).
119        // This works with ServiceAccountKey but may fail with AccessToken,
120        // ServiceMetadata, or ProjectedServiceAccount credentials.
121        let dst = prefixed_path(&self.base_dir, path);
122        let signed_url = self
123            .inner
124            .signed_url(Method::PUT, &dst, expires_in)
125            .await
126            .into_alien_error()
127            .context(ErrorData::StorageOperationFailed {
128                binding_name: "gcp-gcs".to_string(),
129                operation: format!("generate presigned PUT URL for {}", path),
130            })?;
131
132        let headers = HashMap::new();
133
134        Ok(PresignedRequest {
135            backend: PresignedRequestBackend::Http {
136                url: signed_url.to_string(),
137                method: "PUT".to_string(),
138                headers,
139            },
140            expiration: Utc::now()
141                + chrono::Duration::from_std(expires_in).map_err(|e| {
142                    AlienError::new(ErrorData::Other {
143                        message: format!("Invalid duration: {}", e),
144                    })
145                })?,
146            operation: PresignedOperation::Put,
147            path: path.to_string(),
148        })
149    }
150
151    async fn presigned_get(
152        &self,
153        path: &Path,
154        expires_in: Duration,
155    ) -> crate::error::Result<PresignedRequest> {
156        let dst = prefixed_path(&self.base_dir, path);
157        let signed_url = self
158            .inner
159            .signed_url(Method::GET, &dst, expires_in)
160            .await
161            .into_alien_error()
162            .context(ErrorData::StorageOperationFailed {
163                binding_name: "gcp-gcs".to_string(),
164                operation: format!("generate presigned GET URL for {}", path),
165            })?;
166
167        let headers = HashMap::new();
168
169        Ok(PresignedRequest {
170            backend: PresignedRequestBackend::Http {
171                url: signed_url.to_string(),
172                method: "GET".to_string(),
173                headers,
174            },
175            expiration: Utc::now()
176                + chrono::Duration::from_std(expires_in).map_err(|e| {
177                    AlienError::new(ErrorData::Other {
178                        message: format!("Invalid duration: {}", e),
179                    })
180                })?,
181            operation: PresignedOperation::Get,
182            path: path.to_string(),
183        })
184    }
185
186    async fn presigned_delete(
187        &self,
188        path: &Path,
189        expires_in: Duration,
190    ) -> crate::error::Result<PresignedRequest> {
191        let dst = prefixed_path(&self.base_dir, path);
192        let signed_url = self
193            .inner
194            .signed_url(Method::DELETE, &dst, expires_in)
195            .await
196            .into_alien_error()
197            .context(ErrorData::StorageOperationFailed {
198                binding_name: "gcp-gcs".to_string(),
199                operation: format!("generate presigned DELETE URL for {}", path),
200            })?;
201
202        let headers = HashMap::new();
203
204        Ok(PresignedRequest {
205            backend: PresignedRequestBackend::Http {
206                url: signed_url.to_string(),
207                method: "DELETE".to_string(),
208                headers,
209            },
210            expiration: Utc::now()
211                + chrono::Duration::from_std(expires_in).map_err(|e| {
212                    AlienError::new(ErrorData::Other {
213                        message: format!("Invalid duration: {}", e),
214                    })
215                })?,
216            operation: PresignedOperation::Delete,
217            path: path.to_string(),
218        })
219    }
220}
221
222// Delegate ObjectStore trait implementation to the inner store,
223// prefixing paths with the base_dir.
224#[async_trait]
225impl ObjectStore for GcsStorage {
226    async fn put(&self, location: &Path, payload: PutPayload) -> ObjectStoreResult<PutResult> {
227        let dst = prefixed_path(&self.base_dir, location);
228        self.inner.put(&dst, payload).await
229    }
230
231    async fn put_opts(
232        &self,
233        location: &Path,
234        payload: PutPayload,
235        opts: PutOptions,
236    ) -> ObjectStoreResult<PutResult> {
237        let dst = prefixed_path(&self.base_dir, location);
238        validate_put_attributes(&opts.attributes)?;
239        self.inner.put_opts(&dst, payload, opts).await
240    }
241
242    async fn put_multipart(
243        &self,
244        location: &Path,
245    ) -> ObjectStoreResult<Box<dyn object_store::MultipartUpload>> {
246        let dst = prefixed_path(&self.base_dir, location);
247        self.inner.put_multipart(&dst).await
248    }
249
250    async fn put_multipart_opts(
251        &self,
252        location: &Path,
253        opts: PutMultipartOptions,
254    ) -> ObjectStoreResult<Box<dyn object_store::MultipartUpload>> {
255        let dst = prefixed_path(&self.base_dir, location);
256        validate_put_attributes(&opts.attributes)?;
257        self.inner.put_multipart_opts(&dst, opts).await
258    }
259
260    async fn get(&self, location: &Path) -> ObjectStoreResult<GetResult> {
261        let src = prefixed_path(&self.base_dir, location);
262        self.inner.get(&src).await
263    }
264
265    async fn get_opts(&self, location: &Path, options: GetOptions) -> ObjectStoreResult<GetResult> {
266        let src = prefixed_path(&self.base_dir, location);
267        self.inner.get_opts(&src, options).await
268    }
269
270    async fn get_range(
271        &self,
272        location: &Path,
273        range: std::ops::Range<u64>,
274    ) -> ObjectStoreResult<Bytes> {
275        let src = prefixed_path(&self.base_dir, location);
276        self.inner.get_range(&src, range).await
277    }
278
279    async fn head(&self, location: &Path) -> ObjectStoreResult<ObjectMeta> {
280        let src = prefixed_path(&self.base_dir, location);
281        let mut meta = self.inner.head(&src).await?;
282        meta.location = relativize_path(&self.base_dir, meta.location, "GcpStorage")?;
283        Ok(meta)
284    }
285
286    async fn delete(&self, location: &Path) -> ObjectStoreResult<()> {
287        let src = prefixed_path(&self.base_dir, location);
288        self.inner.delete(&src).await
289    }
290
291    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
292        let list_prefix_for_inner = prefix
293            .map(|p| prefixed_path(&self.base_dir, p))
294            .unwrap_or_else(|| self.base_dir.clone());
295
296        let base_dir_for_stream = self.base_dir.clone();
297
298        Box::pin(
299            self.inner
300                .list(Some(&list_prefix_for_inner))
301                .and_then(move |mut meta| {
302                    let captured_base_dir = base_dir_for_stream.clone();
303                    async move {
304                        meta.location =
305                            relativize_path(&captured_base_dir, meta.location, "GcpStorage")?;
306                        Ok(meta)
307                    }
308                }),
309        )
310    }
311
312    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> ObjectStoreResult<ListResult> {
313        let list_prefix_for_inner = prefix
314            .map(|p| prefixed_path(&self.base_dir, p))
315            .unwrap_or_else(|| self.base_dir.clone());
316        let mut result = self
317            .inner
318            .list_with_delimiter(Some(&list_prefix_for_inner))
319            .await?;
320
321        for meta_obj in &mut result.objects {
322            let original_location = std::mem::take(&mut meta_obj.location);
323            meta_obj.location = relativize_path(&self.base_dir, original_location, "GcpStorage")?;
324        }
325
326        let mut new_common_prefixes = Vec::with_capacity(result.common_prefixes.len());
327        for cp in result.common_prefixes {
328            new_common_prefixes.push(relativize_path(&self.base_dir, cp, "GcpStorage")?);
329        }
330        result.common_prefixes = new_common_prefixes;
331
332        Ok(result)
333    }
334
335    async fn copy(&self, from: &Path, to: &Path) -> ObjectStoreResult<()> {
336        let src = prefixed_path(&self.base_dir, from);
337        let dst = prefixed_path(&self.base_dir, to);
338        self.inner.copy(&src, &dst).await
339    }
340
341    async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> ObjectStoreResult<()> {
342        let src = prefixed_path(&self.base_dir, from);
343        let dst = prefixed_path(&self.base_dir, to);
344        self.inner.copy_if_not_exists(&src, &dst).await
345    }
346}
347
348impl std::fmt::Display for GcsStorage {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        write!(f, "GcpStorage(url={})", self.url)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use object_store::AttributeValue;
358
359    #[test]
360    fn rejects_gzip_content_encoding_before_upload() {
361        for encoding in ["gzip", "GZip"] {
362            let attributes = Attributes::from_iter([(
363                Attribute::ContentEncoding,
364                AttributeValue::from(encoding),
365            )]);
366
367            let error = validate_put_attributes(&attributes)
368                .expect_err("gzip would make the object unreadable through this backend");
369
370            assert!(matches!(error, object_store::Error::Generic { .. }));
371            assert!(error.to_string().contains("decompressive transcoding"));
372        }
373    }
374
375    #[test]
376    fn permits_non_transcoded_content_encoding() {
377        let attributes =
378            Attributes::from_iter([(Attribute::ContentEncoding, AttributeValue::from("br"))]);
379
380        validate_put_attributes(&attributes)
381            .expect("Brotli objects are served without transcoding");
382    }
383}