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#[derive(Debug)]
28pub struct GcsStorage {
29 url: Url,
30 base_dir: Path,
31 inner: GoogleCloudStorage,
32}
33
34impl GcsStorage {
35 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 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 let base_dir = match url.path_segments() {
72 Some(segments) => Path::from_iter(segments.filter(|s| !s.is_empty())),
73 None => Path::default(), };
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 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#[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}