Skip to main content

objectstore_service/backend/
gcs.rs

1//! Google Cloud Storage backend for long-term storage of large objects.
2
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::future::Future;
6use std::time::SystemTime;
7use std::{fmt, io};
8
9use anyhow::Context;
10use futures_util::{StreamExt, TryStreamExt};
11use gcp_auth::TokenProvider;
12use objectstore_types::headers;
13use objectstore_types::metadata::{ExpirationPolicy, Metadata};
14use objectstore_types::range::{ByteRange, ContentRange};
15use reqwest::header::HeaderName;
16use reqwest::{Body, IntoUrl, Method, RequestBuilder, StatusCode, Url, header, multipart};
17use serde::{Deserialize, Serialize};
18
19use super::extensions::{ResponseExt, SendTraced};
20use crate::backend::common::{
21    self, Backend, DeleteResponse, GetResponse, MetadataResponse, MultipartUploadBackend,
22    PutResponse,
23};
24use crate::error::{Error, Result};
25use crate::gcp_auth::PrefetchingTokenProvider;
26use crate::id::ObjectId;
27use crate::multipart::{
28    AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
29    ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
30};
31use crate::stream::ClientStream;
32
33/// Configuration for [`GcsBackend`].
34///
35/// Stores objects in [Google Cloud Storage]. Authentication uses Application Default Credentials
36/// (ADC), which can be provided via the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or
37/// the GCE/GKE metadata service.
38///
39/// **Note**: The bucket must be pre-created with the following lifecycle policy:
40/// - `daysSinceCustomTime`: 1 day
41/// - `action`: delete
42///
43/// [Google Cloud Storage]: https://cloud.google.com/storage
44///
45/// # Example
46///
47/// ```yaml
48/// storage:
49///   type: gcs
50///   bucket: objectstore-bucket
51/// ```
52#[derive(Debug, Clone, Deserialize, Serialize)]
53pub struct GcsConfig {
54    /// Optional custom GCS endpoint URL.
55    ///
56    /// Useful for testing with emulators. If `None`, uses the default GCS endpoint.
57    ///
58    /// # Default
59    ///
60    /// `None` (uses default GCS endpoint)
61    ///
62    /// # Environment Variables
63    ///
64    /// - `OS__STORAGE__TYPE=gcs`
65    /// - `OS__STORAGE__ENDPOINT=http://localhost:9000` (optional)
66    pub endpoint: Option<String>,
67
68    /// GCS bucket name.
69    ///
70    /// The bucket must exist before starting the server.
71    ///
72    /// # Environment Variables
73    ///
74    /// - `OS__STORAGE__BUCKET=my-gcs-bucket`
75    pub bucket: String,
76}
77
78/// Default endpoint used to access the GCS JSON API.
79const DEFAULT_ENDPOINT: &str = "https://storage.googleapis.com";
80/// Permission scopes required for accessing GCS.
81const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/devstorage.read_write"];
82/// How many times to retry failed operations.
83const REQUEST_RETRY_COUNT: usize = 2;
84
85/// Prefix for our built-in metadata stored in GCS metadata field
86const BUILTIN_META_PREFIX: &str = "x-sn-";
87/// Prefix for user custom metadata stored in GCS metadata field
88const CUSTOM_META_PREFIX: &str = "x-snme-";
89
90/// GCS object resource.
91///
92/// This is the representation of the object resource in GCS JSON API without its payload. Where no
93/// dedicated fields are available, we encode both built-in and custom metadata in the `metadata`
94/// field.
95#[derive(Debug, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct GcsObject {
98    /// Content-Type of the object data. If an object is stored without a Content-Type, it is served
99    /// as application/octet-stream.
100    pub content_type: Cow<'static, str>,
101
102    /// Content encoding, used to store [`Metadata::compression`].
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub content_encoding: Option<String>,
105
106    /// Custom time stamp used for time-based expiration.
107    #[serde(
108        default,
109        skip_serializing_if = "Option::is_none",
110        with = "humantime_serde"
111    )]
112    pub custom_time: Option<SystemTime>,
113
114    /// The `Content-Length` of the data in bytes. GCS returns this as a string.
115    ///
116    /// GCS sets this in metadata responses. We can use it to know the size of an object
117    /// without having to stream it.
118    pub size: Option<String>,
119
120    /// Timestamp of when this object was created.
121    #[serde(
122        default,
123        skip_serializing_if = "Option::is_none",
124        with = "humantime_serde"
125    )]
126    pub time_created: Option<SystemTime>,
127
128    /// User-provided metadata, including our built-in metadata.
129    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130    pub metadata: BTreeMap<GcsMetaKey, String>,
131
132    /// Version of the object's contents.
133    #[serde(skip_serializing)]
134    pub generation: String,
135
136    /// Version of the object's metadata.
137    #[serde(skip_serializing)]
138    pub metageneration: String,
139}
140
141impl GcsObject {
142    /// Converts our Metadata type to GCS JSON object metadata.
143    pub fn from_metadata(metadata: &Metadata) -> Self {
144        let mut gcs_object = GcsObject {
145            content_type: metadata.content_type.clone(),
146            size: metadata.size.map(|size| size.to_string()),
147            content_encoding: None,
148            custom_time: None,
149            time_created: metadata.time_created,
150            metadata: BTreeMap::new(),
151            generation: String::new(),
152            metageneration: String::new(),
153        };
154
155        // For time-based expiration, set the `customTime` field. The bucket must have a
156        // `daysSinceCustomTime` lifecycle rule configured to delete objects with this field set.
157        // This rule automatically skips objects without `customTime` set.
158        gcs_object.custom_time = metadata.time_expires;
159
160        if let Some(compression) = metadata.compression {
161            gcs_object.content_encoding = Some(compression.to_string());
162        }
163
164        if metadata.expiration_policy != ExpirationPolicy::default() {
165            gcs_object.metadata.insert(
166                GcsMetaKey::Expiration,
167                metadata.expiration_policy.to_string(),
168            );
169        }
170
171        // Free-form strings are stored escaped, even though this JSON representation could carry
172        // them verbatim. See `insert_gcs_meta_header` for why, and why both writers must agree.
173        if let Some(origin) = &metadata.origin {
174            gcs_object.metadata.insert(
175                GcsMetaKey::Origin,
176                headers::encode_header_str(origin).into(),
177            );
178        }
179
180        if let Some(filename) = &metadata.filename {
181            gcs_object.metadata.insert(
182                GcsMetaKey::Filename,
183                headers::encode_header_str(filename).into(),
184            );
185        }
186
187        for (key, value) in &metadata.custom {
188            gcs_object.metadata.insert(
189                GcsMetaKey::Custom(key.clone()),
190                headers::encode_header_str(value).into(),
191            );
192        }
193
194        gcs_object
195    }
196
197    /// Converts GCS JSON object metadata to our Metadata type.
198    pub fn into_metadata(mut self) -> Result<Metadata> {
199        // Remove ignored metadata keys that are set by the GCS emulator.
200        self.metadata.remove(&GcsMetaKey::EmulatorIgnored);
201
202        let expiration_policy = self
203            .metadata
204            .remove(&GcsMetaKey::Expiration)
205            .map(|s| s.parse())
206            .transpose()?
207            .unwrap_or_default();
208
209        let origin = self
210            .metadata
211            .remove(&GcsMetaKey::Origin)
212            .map(|value| decode_gcs_meta_value(&value))
213            .transpose()?;
214        let filename = self
215            .metadata
216            .remove(&GcsMetaKey::Filename)
217            .map(|value| decode_gcs_meta_value(&value))
218            .transpose()?;
219
220        let content_type = self.content_type;
221        let compression = self.content_encoding.map(|s| s.parse()).transpose()?;
222        let size = self
223            .size
224            .map(|size| size.parse())
225            .transpose()
226            .map_err(|e| Error::Generic {
227                context: "GCS: failed to parse size from object metadata".to_string(),
228                cause: Some(Box::new(e)),
229            })?;
230        let time_created = self.time_created;
231
232        // At this point, all built-in metadata should have been removed from self.metadata.
233        let mut custom = BTreeMap::new();
234        for (key, value) in self.metadata {
235            if let GcsMetaKey::Custom(custom_key) = key {
236                custom.insert(custom_key, decode_gcs_meta_value(&value)?);
237            } else {
238                return Err(Error::Generic {
239                    context: format!(
240                        "GCS: unexpected built-in metadata key in object metadata: {key}"
241                    ),
242                    cause: None,
243                });
244            }
245        }
246
247        Ok(Metadata {
248            content_type,
249            expiration_policy,
250            compression,
251            origin,
252            filename,
253            size,
254            custom,
255            time_created,
256            time_expires: self.custom_time,
257        })
258    }
259}
260
261/// Key for [`GcsObject::metadata`].
262#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
263enum GcsMetaKey {
264    /// Built-in metadata key for [`Metadata::expiration_policy`].
265    Expiration,
266    /// Built-in metadata key for [`Metadata::origin`].
267    Origin,
268    /// Built-in metadata key for [`Metadata::filename`].
269    Filename,
270    /// Ignored metadata set by the GCS emulator.
271    EmulatorIgnored,
272    /// User-defined custom metadata key.
273    Custom(String),
274}
275
276impl std::str::FromStr for GcsMetaKey {
277    type Err = anyhow::Error;
278
279    fn from_str(s: &str) -> Result<Self, Self::Err> {
280        if matches!(s, "x_emulator_upload" | "x_testbench_upload") {
281            return Ok(GcsMetaKey::EmulatorIgnored);
282        }
283
284        Ok(match s.strip_prefix(BUILTIN_META_PREFIX) {
285            Some("expiration") => GcsMetaKey::Expiration,
286            Some("origin") => GcsMetaKey::Origin,
287            Some("filename") => GcsMetaKey::Filename,
288            Some(unknown) => anyhow::bail!("unknown builtin metadata key: {unknown}"),
289            None => match s.strip_prefix(CUSTOM_META_PREFIX) {
290                Some(key) => GcsMetaKey::Custom(key.to_string()),
291                None => anyhow::bail!("invalid GCS metadata key format: {s}"),
292            },
293        })
294    }
295}
296
297impl fmt::Display for GcsMetaKey {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        match self {
300            Self::Expiration => write!(f, "{BUILTIN_META_PREFIX}expiration"),
301            Self::Origin => write!(f, "{BUILTIN_META_PREFIX}origin"),
302            Self::Filename => write!(f, "{BUILTIN_META_PREFIX}filename"),
303            Self::EmulatorIgnored => unreachable!("do not serialize emulator metadata"),
304            Self::Custom(key) => write!(f, "{CUSTOM_META_PREFIX}{key}"),
305        }
306    }
307}
308
309impl<'de> Deserialize<'de> for GcsMetaKey {
310    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
311    where
312        D: serde::Deserializer<'de>,
313    {
314        let s = Cow::<'de, str>::deserialize(deserializer)?;
315        s.parse().map_err(serde::de::Error::custom)
316    }
317}
318
319impl Serialize for GcsMetaKey {
320    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
321    where
322        S: serde::Serializer,
323    {
324        serializer.collect_str(self)
325    }
326}
327
328/// Builds HTTP headers that encode `metadata` for a GCS XML API request.
329fn metadata_to_gcs_headers(metadata: &Metadata) -> Result<header::HeaderMap> {
330    let mut headers = header::HeaderMap::new();
331
332    if let Some(custom_time) = metadata.time_expires {
333        let formatted = humantime::format_rfc3339_seconds(custom_time);
334        headers.insert(
335            HeaderName::from_static("x-goog-custom-time"),
336            formatted.to_string().parse().map_err(|e| Error::Generic {
337                context: "GCS: invalid custom-time header value".into(),
338                cause: Some(Box::new(e)),
339            })?,
340        );
341    }
342
343    if let Some(compression) = metadata.compression {
344        headers.insert(
345            header::CONTENT_ENCODING,
346            compression
347                .to_string()
348                .parse()
349                .map_err(|e| Error::Generic {
350                    context: "GCS: invalid content-encoding header value".into(),
351                    cause: Some(Box::new(e)),
352                })?,
353        );
354    }
355
356    if metadata.expiration_policy != ExpirationPolicy::default() {
357        insert_gcs_meta_header(
358            &mut headers,
359            &GcsMetaKey::Expiration,
360            &metadata.expiration_policy.to_string(),
361        )?;
362    }
363
364    if let Some(origin) = &metadata.origin {
365        insert_gcs_meta_header(&mut headers, &GcsMetaKey::Origin, origin)?;
366    }
367
368    if let Some(filename) = &metadata.filename {
369        insert_gcs_meta_header(&mut headers, &GcsMetaKey::Filename, filename)?;
370    }
371
372    for (key, value) in &metadata.custom {
373        insert_gcs_meta_header(&mut headers, &GcsMetaKey::Custom(key.clone()), value)?;
374    }
375
376    Ok(headers)
377}
378
379/// Decodes a stored GCS metadata value into its logical string.
380fn decode_gcs_meta_value(value: &str) -> Result<String> {
381    headers::decode_header_str(value).map_err(|cause| Error::Generic {
382        context: "GCS: invalid percent-encoded UTF-8 in object metadata".to_owned(),
383        cause: Some(Box::new(cause)),
384    })
385}
386
387/// Inserts a single `x-goog-meta-*` header, escaping the value for transport.
388///
389/// Google: "you should generally avoid non-ascii characters, because they are not permitted in
390/// HTTP headers, which the XML API uses" ([docs]). Real GCS does preserve raw UTF-8 here, but that
391/// is undocumented, and it still drops leading whitespace and turns invalid UTF-8 into `U+FFFD`.
392///
393/// [`GcsObject::from_metadata`] escapes the same values on the JSON path: reads always come back
394/// through the JSON API and cannot tell which writer produced an object, so both must agree.
395///
396/// [docs]: https://docs.cloud.google.com/storage/docs/metadata
397fn insert_gcs_meta_header(
398    headers: &mut header::HeaderMap,
399    key: &GcsMetaKey,
400    value: &str,
401) -> Result<()> {
402    let header_name = format!("x-goog-meta-{key}");
403    headers.insert(
404        HeaderName::try_from(&header_name).map_err(|e| Error::Generic {
405            context: format!("GCS: invalid header name: {header_name}"),
406            cause: Some(Box::new(e)),
407        })?,
408        headers::encode_header_value(value),
409    );
410    Ok(())
411}
412
413/// Returns `true` if the error is a transient reqwest failure worth retrying.
414fn error_is_retryable(error: &Error) -> bool {
415    match error {
416        Error::Reqwest { cause, .. } => {
417            cause.is_timeout()
418                || cause.is_connect()
419                || cause.is_request()
420                || cause.status().is_some_and(status_is_retryable)
421        }
422        Error::BackendResponse { status, .. } => status_is_retryable(*status),
423        _ => false,
424    }
425}
426
427fn status_is_retryable(status: StatusCode) -> bool {
428    // https://docs.cloud.google.com/storage/docs/json_api/v1/status-codes
429    matches!(
430        status,
431        StatusCode::REQUEST_TIMEOUT
432            | StatusCode::TOO_MANY_REQUESTS
433            | StatusCode::INTERNAL_SERVER_ERROR
434            | StatusCode::BAD_GATEWAY
435            | StatusCode::SERVICE_UNAVAILABLE
436            | StatusCode::GATEWAY_TIMEOUT
437    )
438}
439
440/// GCS JSON API backend for long-term storage of large objects.
441pub struct GcsBackend {
442    client: reqwest::Client,
443    endpoint: Url,
444    bucket: String,
445    token_provider: Option<PrefetchingTokenProvider>,
446}
447
448impl GcsBackend {
449    /// Creates an authenticated GCS JSON API backend bound to the bucket in `config`.
450    pub async fn new(config: GcsConfig) -> anyhow::Result<Self> {
451        let GcsConfig { endpoint, bucket } = config;
452
453        let token_provider = if endpoint.is_none() {
454            Some(PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?)
455        } else {
456            None
457        };
458
459        let endpoint_str = endpoint.as_deref().unwrap_or(DEFAULT_ENDPOINT);
460
461        Ok(Self {
462            client: common::reqwest_client(),
463            endpoint: endpoint_str.parse().context("invalid GCS endpoint URL")?,
464            bucket,
465            token_provider,
466        })
467    }
468
469    /// Formats the GCS object (metadata) URL for the given key.
470    fn object_url(&self, id: &ObjectId) -> Result<Url> {
471        let mut url = self.endpoint.clone();
472
473        let path = id.as_storage_path().to_string();
474        url.path_segments_mut()
475            .map_err(|()| Error::Generic {
476                context: format!(
477                    "GCS: invalid endpoint URL, {} cannot be a base",
478                    self.endpoint
479                ),
480                cause: None,
481            })?
482            .extend(&["storage", "v1", "b", &self.bucket, "o", &path]);
483
484        Ok(url)
485    }
486
487    /// Formats the GCS upload URL for the given upload type.
488    fn upload_url(&self, id: &ObjectId, upload_type: &str) -> Result<Url> {
489        let mut url = self.endpoint.clone();
490
491        url.path_segments_mut()
492            .map_err(|()| Error::Generic {
493                context: format!(
494                    "GCS: invalid endpoint URL, {} cannot be a base",
495                    self.endpoint
496                ),
497                cause: None,
498            })?
499            .extend(&["upload", "storage", "v1", "b", &self.bucket, "o"]);
500
501        url.query_pairs_mut()
502            .append_pair("uploadType", upload_type)
503            .append_pair("name", &id.as_storage_path().to_string());
504
505        Ok(url)
506    }
507
508    /// Formats a GCS XML API URL for the given object.
509    ///
510    /// Unlike [`object_url`](Self::object_url) (JSON API at
511    /// `/storage/v1/b/{bucket}/o/{name}`), this produces
512    /// `/{bucket}/{path_segments}` for the S3-compatible XML API used by
513    /// multipart uploads.
514    fn xml_object_url(&self, id: &ObjectId) -> Result<Url> {
515        let mut url = self.endpoint.clone();
516        {
517            let mut segments = url.path_segments_mut().map_err(|()| Error::Generic {
518                context: format!(
519                    "GCS: invalid endpoint URL, {} cannot be a base",
520                    self.endpoint
521                ),
522                cause: None,
523            })?;
524            segments.push(&self.bucket);
525            for part in id.as_storage_path().to_string().split('/') {
526                segments.push(part);
527            }
528        }
529        Ok(url)
530    }
531
532    /// Creates a request builder with the appropriate authentication.
533    async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
534        let mut builder = self.client.request(method, url);
535        if let Some(provider) = &self.token_provider {
536            let token = provider.token(TOKEN_SCOPES).await?;
537            builder = builder.bearer_auth(token.as_str());
538        }
539        Ok(builder)
540    }
541
542    /// Retries a GCS request on transient errors.
543    async fn with_retry<T, F>(&self, action: &'static str, f: impl Fn() -> F) -> Result<T>
544    where
545        F: Future<Output = Result<T>> + Send,
546    {
547        let mut retry_count = 0usize;
548        loop {
549            match f().await {
550                Ok(res) => return Ok(res),
551                Err(ref e) if retry_count < REQUEST_RETRY_COUNT && error_is_retryable(e) => {
552                    retry_count += 1;
553                    objectstore_metrics::count!("gcs.retries", action = action);
554                    objectstore_log::warn!(!!e, retry_count, action, "Retrying request");
555                }
556                Err(e) => {
557                    objectstore_metrics::count!("gcs.failures", action = action);
558                    return Err(e);
559                }
560            }
561        }
562    }
563
564    /// Fetches the GCS object metadata (without the payload), bumps TTI if
565    /// needed, and returns the parsed [`Metadata`].
566    #[tracing::instrument(level = "debug", fields(%object_url), skip(self))]
567    async fn fetch_gcs_metadata(&self, object_url: &Url) -> Result<Option<Metadata>> {
568        let metadata_opt = self
569            .with_retry("get_metadata", || async {
570                let resp = self
571                    .request(Method::GET, object_url.clone())
572                    .await?
573                    .send_traced()
574                    .await
575                    .map_err(|e| Error::reqwest("GCS: get metadata request", e))?;
576
577                if resp.status() == StatusCode::NOT_FOUND {
578                    resp.drain_body().await;
579                    return Ok(None);
580                }
581
582                let metadata: GcsObject = resp
583                    .check_error("GCS: get metadata status")
584                    .await?
585                    .json()
586                    .await
587                    .map_err(|e| Error::reqwest("GCS: get metadata parse", e))?;
588
589                Ok(Some(metadata))
590            })
591            .await?;
592
593        let Some(gcs_metadata) = metadata_opt else {
594            objectstore_log::debug!("Object not found");
595            return Ok(None);
596        };
597
598        let generation = gcs_metadata.generation.clone();
599        let metageneration = gcs_metadata.metageneration.clone();
600        let metadata = gcs_metadata.into_metadata()?;
601
602        // TODO: Inject the access time from the request.
603        let access_time = SystemTime::now();
604
605        // Filter already expired objects but leave them to garbage collection
606        if metadata.expiration_policy.is_timeout()
607            && metadata.time_expires.is_some_and(|ts| ts < access_time)
608        {
609            objectstore_log::debug!("Object found but past expiry");
610            return Ok(None);
611        }
612
613        // TODO: Schedule into background persistently so this doesn't get lost on restarts
614        if let Some(new_expire_at) = metadata.check_tti_bump(access_time) {
615            self.update_custom_time(
616                object_url.clone(),
617                new_expire_at,
618                &generation,
619                &metageneration,
620            )
621            .await?;
622        }
623
624        Ok(Some(metadata))
625    }
626
627    #[tracing::instrument(level = "debug", fields(%object_url), skip(self))]
628    async fn update_custom_time(
629        &self,
630        object_url: Url,
631        custom_time: SystemTime,
632        generation: &str,
633        metageneration: &str,
634    ) -> Result<()> {
635        #[derive(Debug, Serialize)]
636        #[serde(rename_all = "camelCase")]
637        struct CustomTimeRequest {
638            #[serde(with = "humantime_serde")]
639            custom_time: SystemTime,
640        }
641
642        let mut object_url = object_url;
643        object_url
644            .query_pairs_mut()
645            .append_pair("ifGenerationMatch", generation)
646            .append_pair("ifMetagenerationMatch", metageneration);
647
648        self.with_retry("update_custom_time", || async {
649            match self
650                .request(Method::PATCH, object_url.clone())
651                .await?
652                .json(&CustomTimeRequest { custom_time })
653                .send_traced()
654                .await
655                .check_error("GCS: update custom time")
656                .await
657            {
658                Ok(response) => {
659                    response.drain_body().await;
660                    Ok(())
661                }
662                // Bumping TTI is opportunistic. A concurrent metadata writer won the CAS race,
663                // so leave its update intact and let a future read evaluate the TTI again.
664                Err(Error::BackendResponse {
665                    status: StatusCode::PRECONDITION_FAILED,
666                    ..
667                }) => Ok(()),
668                Err(error) => Err(error),
669            }
670        })
671        .await
672    }
673}
674
675impl fmt::Debug for GcsBackend {
676    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
677        f.debug_struct("GcsJsonApi")
678            .field("endpoint", &self.endpoint)
679            .field("bucket", &self.bucket)
680            .finish_non_exhaustive()
681    }
682}
683
684#[async_trait::async_trait]
685impl Backend for GcsBackend {
686    fn name(&self) -> &'static str {
687        "gcs"
688    }
689
690    fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
691        Ok(self)
692    }
693
694    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
695    async fn put_object(
696        &self,
697        id: &ObjectId,
698        metadata: &Metadata,
699        stream: ClientStream,
700    ) -> Result<PutResponse> {
701        objectstore_log::debug!("Writing to GCS backend");
702        let gcs_metadata = GcsObject::from_metadata(metadata);
703
704        // NB: Ensure the order of these fields and that a content-type is attached to them. Both
705        // are required by the GCS API.
706        let metadata_json = serde_json::to_string(&gcs_metadata).map_err(|cause| Error::Serde {
707            context: "failed to serialize metadata for GCS upload".to_string(),
708            cause,
709        })?;
710
711        let multipart = multipart::Form::new()
712            .part(
713                "metadata",
714                multipart::Part::text(metadata_json)
715                    .mime_str("application/json")
716                    .expect("application/json is a valid mime type"),
717            )
718            .part(
719                "media",
720                multipart::Part::stream(Body::wrap_stream(stream))
721                    .mime_str(&metadata.content_type)
722                    .map_err(|e| Error::Generic {
723                        context: format!("invalid mime type: {}", metadata.content_type),
724                        cause: Some(Box::new(e)),
725                    })?,
726            );
727
728        // GCS requires a multipart/related request. Its body looks identical to
729        // multipart/form-data, but the Content-Type header is different. Hence, we have to manually
730        // set the header *after* writing the multipart form into the request.
731        let content_type = format!("multipart/related; boundary={}", multipart.boundary());
732
733        self.request(Method::POST, self.upload_url(id, "multipart")?)
734            .await?
735            .multipart(multipart)
736            .header(header::CONTENT_TYPE, content_type)
737            .send_traced()
738            .await
739            .check_error("GCS: upload object")
740            .await?
741            .drain_body()
742            .await;
743
744        Ok(())
745    }
746
747    #[tracing::instrument(level = "debug", skip(self))]
748    async fn get_object(&self, id: &ObjectId, range: Option<ByteRange>) -> Result<GetResponse> {
749        objectstore_log::debug!("Reading from GCS backend");
750        let object_url = self.object_url(id)?;
751
752        let Some(metadata) = self.fetch_gcs_metadata(&object_url).await? else {
753            return Ok(None);
754        };
755
756        let mut download_url = object_url;
757        download_url.query_pairs_mut().append_pair("alt", "media");
758
759        let payload_response = self
760            .with_retry("get_payload", || async {
761                let mut req = self.request(Method::GET, download_url.clone()).await?;
762                if let Some(r) = range {
763                    req = req.header(header::RANGE, r.to_header_value());
764                }
765                let resp = req
766                    .send_traced()
767                    .await
768                    .map_err(|e| Error::reqwest("GCS: get payload", e))?;
769
770                if resp.status() == StatusCode::RANGE_NOT_SATISFIABLE {
771                    let raw = resp
772                        .headers()
773                        .get(header::CONTENT_RANGE)
774                        .and_then(|v| v.to_str().ok());
775                    let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
776                    let err = match total {
777                        Some(total) => Error::RangeNotSatisfiable { total },
778                        None => Error::generic(format!(
779                            "GCS: 416 response with invalid Content-Range: {raw:?}"
780                        )),
781                    };
782                    resp.drain_body().await;
783                    return Err(err);
784                }
785
786                resp.check_error("GCS: get payload").await
787            })
788            .await?;
789
790        let content_range = if payload_response.status() == StatusCode::PARTIAL_CONTENT {
791            Some(
792                payload_response
793                    .headers()
794                    .get(header::CONTENT_RANGE)
795                    .and_then(|v| v.to_str().ok())
796                    .and_then(|s| s.parse::<ContentRange>().ok())
797                    .ok_or_else(|| Error::Generic {
798                        context: "GCS: 206 response missing valid Content-Range header".to_owned(),
799                        cause: None,
800                    })?,
801            )
802        } else {
803            None
804        };
805
806        let stream = payload_response
807            .bytes_stream()
808            .map_err(io::Error::other)
809            .boxed();
810
811        Ok(Some((metadata, content_range, stream)))
812    }
813
814    #[tracing::instrument(level = "debug", skip(self))]
815    async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
816        objectstore_log::debug!("Reading metadata from GCS backend");
817        let object_url = self.object_url(id)?;
818        self.fetch_gcs_metadata(&object_url).await
819    }
820
821    #[tracing::instrument(level = "debug", skip(self))]
822    async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse> {
823        objectstore_log::debug!("Deleting from GCS backend");
824        let object_url = self.object_url(id)?;
825
826        self.with_retry("delete", || async {
827            let resp = self
828                .request(Method::DELETE, object_url.clone())
829                .await?
830                .send_traced()
831                .await
832                .map_err(|e| Error::reqwest("GCS: delete object", e))?;
833
834            // Do not error for objects that do not exist
835            if resp.status() == StatusCode::NOT_FOUND {
836                resp.drain_body().await;
837                return Ok(());
838            }
839
840            resp.check_error("GCS: delete object")
841                .await?
842                .drain_body()
843                .await;
844
845            Ok(())
846        })
847        .await
848    }
849}
850
851#[derive(Debug, Deserialize)]
852#[serde(rename_all = "PascalCase")]
853struct XmlInitiateMultipartUploadResponse {
854    upload_id: String,
855}
856
857impl TryFrom<XmlInitiateMultipartUploadResponse> for InitiateMultipartResponse {
858    type Error = Error;
859
860    fn try_from(r: XmlInitiateMultipartUploadResponse) -> Result<Self> {
861        Ok(UploadId::new(r.upload_id)?)
862    }
863}
864
865#[derive(Debug, Deserialize)]
866#[serde(rename_all = "PascalCase")]
867struct XmlListPartsResponse {
868    #[serde(default)]
869    is_truncated: bool,
870    next_part_number_marker: Option<PartNumber>,
871    #[serde(default, rename = "Part")]
872    parts: Vec<XmlPart>,
873}
874
875impl From<XmlListPartsResponse> for ListPartsResponse {
876    fn from(xml: XmlListPartsResponse) -> Self {
877        Self {
878            parts: xml.parts.into_iter().map(Into::into).collect(),
879            is_truncated: xml.is_truncated,
880            next_part_number_marker: xml.next_part_number_marker,
881        }
882    }
883}
884
885#[derive(Debug, Deserialize)]
886#[serde(rename_all = "PascalCase")]
887struct XmlPart {
888    part_number: PartNumber,
889    #[serde(rename = "ETag")]
890    e_tag: String,
891    #[serde(with = "humantime_serde")]
892    last_modified: SystemTime,
893    size: u64,
894}
895
896impl From<XmlPart> for crate::multipart::Part {
897    fn from(p: XmlPart) -> Self {
898        Self {
899            part_number: p.part_number,
900            etag: p.e_tag,
901            last_modified: p.last_modified,
902            size: p.size,
903        }
904    }
905}
906
907#[derive(Debug, Serialize)]
908#[serde(rename = "CompleteMultipartUpload")]
909struct XmlCompleteMultipartUpload {
910    #[serde(rename = "Part")]
911    parts: Vec<XmlCompletePart>,
912}
913
914impl From<Vec<CompletedPart>> for XmlCompleteMultipartUpload {
915    fn from(parts: Vec<CompletedPart>) -> Self {
916        Self {
917            parts: parts.into_iter().map(Into::into).collect(),
918        }
919    }
920}
921
922#[derive(Debug, Serialize)]
923#[serde(rename_all = "PascalCase")]
924struct XmlCompletePart {
925    part_number: PartNumber,
926    #[serde(rename = "ETag")]
927    e_tag: String,
928}
929
930impl From<CompletedPart> for XmlCompletePart {
931    fn from(p: CompletedPart) -> Self {
932        Self {
933            part_number: p.part_number,
934            e_tag: p.etag,
935        }
936    }
937}
938
939#[derive(Debug, Deserialize)]
940#[serde(rename = "Error", rename_all = "PascalCase")]
941struct XmlError {
942    code: String,
943    message: String,
944}
945
946impl From<XmlError> for crate::multipart::CompleteMultipartError {
947    fn from(e: XmlError) -> Self {
948        Self {
949            code: e.code,
950            message: e.message,
951        }
952    }
953}
954
955/// XXX: Any change that affects this implementation should be manually tested against real GCS.
956/// That's because the fork of [storage-testbench](https://github.com/googleapis/storage-testbench)
957/// that we test against has an incomplete implementation of the XML multipart API that likely doesn't match GCS's behavior in many cases.
958#[async_trait::async_trait]
959impl MultipartUploadBackend for GcsBackend {
960    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
961    async fn initiate_multipart(
962        &self,
963        id: &ObjectId,
964        metadata: &Metadata,
965    ) -> Result<InitiateMultipartResponse> {
966        objectstore_log::debug!("Initiating multipart upload on GCS backend");
967        let mut url = self.xml_object_url(id)?;
968        url.set_query(Some("uploads"));
969
970        let mut headers = metadata_to_gcs_headers(metadata)?;
971        headers.insert(
972            header::CONTENT_TYPE,
973            metadata.content_type.parse().map_err(|e| Error::Generic {
974                context: "GCS: invalid content-type header value".into(),
975                cause: Some(Box::new(e)),
976            })?,
977        );
978        headers.insert(
979            header::CONTENT_LENGTH,
980            header::HeaderValue::from_static("0"),
981        );
982
983        self.with_retry("initiate_multipart", || {
984            let url = url.clone();
985            let headers = headers.clone();
986            async move {
987                let resp = self
988                    .request(Method::POST, url)
989                    .await?
990                    .headers(headers)
991                    .send_traced()
992                    .await
993                    .check_error("GCS: initiate multipart upload")
994                    .await?;
995
996                let body = resp
997                    .bytes()
998                    .await
999                    .map_err(|e| Error::reqwest("GCS: read initiate multipart body", e))?;
1000
1001                let xml: XmlInitiateMultipartUploadResponse =
1002                    quick_xml::de::from_reader(body.as_ref()).map_err(|e| Error::Generic {
1003                        context: "GCS: failed to parse initiate multipart response".to_owned(),
1004                        cause: Some(Box::new(e)),
1005                    })?;
1006
1007                xml.try_into()
1008            }
1009        })
1010        .await
1011    }
1012
1013    #[tracing::instrument(level = "debug", skip(self, content_md5, body))]
1014    async fn upload_part(
1015        &self,
1016        id: &ObjectId,
1017        upload_id: &UploadId,
1018        part_number: PartNumber,
1019        content_length: u64,
1020        content_md5: Option<&str>,
1021        body: ClientStream,
1022    ) -> Result<UploadPartResponse> {
1023        objectstore_log::debug!("Uploading part to GCS backend");
1024        let mut url = self.xml_object_url(id)?;
1025        url.query_pairs_mut()
1026            .append_pair("partNumber", &part_number.to_string())
1027            .append_pair("uploadId", upload_id);
1028
1029        let mut builder = self
1030            .request(Method::PUT, url)
1031            .await?
1032            .header(header::CONTENT_LENGTH, content_length)
1033            .body(Body::wrap_stream(body));
1034
1035        if let Some(md5) = content_md5 {
1036            builder = builder.header("content-md5", md5);
1037        }
1038
1039        let resp = builder
1040            .send_traced()
1041            .await
1042            .check_error("GCS: upload part")
1043            .await?;
1044
1045        let etag = resp
1046            .headers()
1047            .get(header::ETAG)
1048            .and_then(|v| v.to_str().ok())
1049            .map(|s| s.to_owned())
1050            .ok_or_else(|| Error::generic("GCS: upload part response missing ETag header"))?;
1051
1052        resp.drain_body().await;
1053
1054        Ok(etag)
1055    }
1056
1057    #[tracing::instrument(level = "debug", skip(self))]
1058    async fn list_parts(
1059        &self,
1060        id: &ObjectId,
1061        upload_id: &UploadId,
1062        max_parts: Option<u32>,
1063        part_number_marker: Option<PartNumber>,
1064    ) -> Result<ListPartsResponse> {
1065        objectstore_log::debug!("Listing parts on GCS backend");
1066        let mut url = self.xml_object_url(id)?;
1067        {
1068            let mut pairs = url.query_pairs_mut();
1069            pairs.append_pair("uploadId", upload_id);
1070            if let Some(max) = max_parts {
1071                pairs.append_pair("max-parts", &max.to_string());
1072            }
1073            if let Some(marker) = part_number_marker {
1074                pairs.append_pair("part-number-marker", &marker.to_string());
1075            }
1076        }
1077
1078        self.with_retry("list_parts", || {
1079            let url = url.clone();
1080            async move {
1081                let resp = self
1082                    .request(Method::GET, url)
1083                    .await?
1084                    .send_traced()
1085                    .await
1086                    .check_error("GCS: list parts")
1087                    .await?;
1088
1089                let body = resp
1090                    .bytes()
1091                    .await
1092                    .map_err(|e| Error::reqwest("GCS: read list parts body", e))?;
1093
1094                let xml: XmlListPartsResponse =
1095                    quick_xml::de::from_reader(body.as_ref()).map_err(|e| Error::Generic {
1096                        context: "GCS: failed to parse list parts response".to_owned(),
1097                        cause: Some(Box::new(e)),
1098                    })?;
1099
1100                Ok(xml.into())
1101            }
1102        })
1103        .await
1104    }
1105
1106    #[tracing::instrument(level = "debug", skip(self))]
1107    async fn abort_multipart(
1108        &self,
1109        id: &ObjectId,
1110        upload_id: &UploadId,
1111    ) -> Result<AbortMultipartResponse> {
1112        objectstore_log::debug!("Aborting multipart upload on GCS backend");
1113        let mut url = self.xml_object_url(id)?;
1114        url.query_pairs_mut().append_pair("uploadId", upload_id);
1115
1116        self.with_retry("abort_multipart", || {
1117            let url = url.clone();
1118            async move {
1119                let resp = self
1120                    .request(Method::DELETE, url)
1121                    .await?
1122                    .send_traced()
1123                    .await
1124                    .map_err(|e| Error::reqwest("GCS: abort multipart upload", e))?;
1125
1126                // XXX: real S3 would return 404 here if the upload has been recently completed and we
1127                // would have to handle it. It turns out GCS returns 204 instead, so we don't need to
1128                // handle that case.
1129
1130                resp.check_error("GCS: abort multipart upload")
1131                    .await?
1132                    .drain_body()
1133                    .await;
1134
1135                Ok(())
1136            }
1137        })
1138        .await
1139    }
1140
1141    #[tracing::instrument(level = "debug", skip(self, parts))]
1142    async fn complete_multipart(
1143        &self,
1144        id: &ObjectId,
1145        upload_id: &UploadId,
1146        parts: Vec<CompletedPart>,
1147    ) -> Result<CompleteMultipartResponse> {
1148        objectstore_log::debug!("Completing multipart upload on GCS backend");
1149        let mut url = self.xml_object_url(id)?;
1150        url.query_pairs_mut().append_pair("uploadId", upload_id);
1151
1152        let body = XmlCompleteMultipartUpload::from(parts);
1153        let xml = quick_xml::se::to_string(&body).map_err(|e| Error::Generic {
1154            context: "GCS: failed to serialize complete multipart request".into(),
1155            cause: Some(Box::new(e)),
1156        })?;
1157
1158        self.with_retry("complete_multipart", || {
1159            let url = url.clone();
1160            let xml = xml.clone();
1161            async move {
1162                let resp = self
1163                    .request(Method::POST, url)
1164                    .await?
1165                    .header(header::CONTENT_TYPE, "application/xml")
1166                    .body(xml)
1167                    .send_traced()
1168                    .await
1169                    .check_error("GCS: complete multipart upload")
1170                    .await?;
1171
1172                // XXX: real S3 would return 404 here if the upload has been recently completed and we
1173                // would have to handle it. It turns out GCS returns 200 instead, so we don't need to
1174                // handle that case.
1175
1176                let body = resp
1177                    .bytes()
1178                    .await
1179                    .map_err(|e| Error::reqwest("GCS: read complete multipart body", e))?;
1180
1181                let error = quick_xml::de::from_reader::<_, XmlError>(body.as_ref())
1182                    .ok()
1183                    .map(Into::into);
1184
1185                Ok(error)
1186            }
1187        })
1188        .await
1189    }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use std::collections::BTreeMap;
1195    use std::num::NonZeroU32;
1196    use std::time::Duration;
1197
1198    use anyhow::Result;
1199    use objectstore_types::scope::{Scope, Scopes};
1200
1201    use super::*;
1202    use crate::id::ObjectContext;
1203    use crate::multipart::CompletedPart;
1204    use crate::stream;
1205
1206    // NB: Not run any of these tests, you need to have a GCS emulator running. This is done
1207    // automatically in CI.
1208    //
1209    // Refer to the readme for how to set up the emulator.
1210
1211    async fn create_test_backend() -> Result<GcsBackend> {
1212        GcsBackend::new(GcsConfig {
1213            endpoint: Some("http://localhost:8087".into()),
1214            bucket: "test-bucket".into(),
1215        })
1216        .await
1217    }
1218
1219    fn make_id() -> ObjectId {
1220        ObjectId::random(ObjectContext {
1221            usecase: "testing".into(),
1222            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1223        })
1224    }
1225
1226    async fn get_generation_matches(
1227        backend: &GcsBackend,
1228        object_url: Url,
1229    ) -> Result<(String, String)> {
1230        Ok(backend
1231            .request(Method::GET, object_url)
1232            .await?
1233            .send_traced()
1234            .await
1235            .check_error("GCS: get metadata request")
1236            .await?
1237            .json::<GcsObject>()
1238            .await
1239            .map_err(|e| Error::reqwest("GCS: get metadata parse", e))
1240            .map(|object| (object.generation, object.metageneration))?)
1241    }
1242
1243    #[tokio::test]
1244    async fn test_roundtrip() -> Result<()> {
1245        let backend = create_test_backend().await?;
1246
1247        let id = make_id();
1248        let metadata = Metadata {
1249            content_type: "text/plain".into(),
1250            expiration_policy: ExpirationPolicy::Manual,
1251            compression: None,
1252            origin: Some("203.0.113.42".into()),
1253            filename: Some("hello.txt".into()),
1254            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1255            time_created: Some(SystemTime::now()),
1256            time_expires: None,
1257            size: None,
1258        };
1259
1260        backend
1261            .put_object(&id, &metadata, stream::single("hello, world"))
1262            .await?;
1263
1264        let (meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1265
1266        let payload = stream::read_to_vec(stream).await?;
1267        let str_payload = str::from_utf8(&payload).unwrap();
1268        assert_eq!(str_payload, "hello, world");
1269        assert_eq!(meta.content_type, metadata.content_type);
1270        assert_eq!(meta.origin, metadata.origin);
1271        assert_eq!(meta.filename, metadata.filename);
1272        assert_eq!(meta.custom, metadata.custom);
1273        assert!(metadata.time_created.is_some());
1274
1275        Ok(())
1276    }
1277
1278    /// Metadata with a non-ASCII filename and custom metadata value.
1279    fn unicode_metadata() -> Metadata {
1280        Metadata {
1281            filename: Some("réport-📄.pdf".into()),
1282            custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
1283            ..Default::default()
1284        }
1285    }
1286
1287    /// Both GCS write paths must agree on how a logical string is stored, because every read goes
1288    /// through the JSON API: `put_object` writes metadata as JSON, `initiate_multipart` writes it
1289    /// as `x-goog-meta-*` request headers.
1290    #[tokio::test]
1291    async fn test_unicode_metadata_roundtrip_json_upload() -> Result<()> {
1292        let backend = create_test_backend().await?;
1293        let id = make_id();
1294        let metadata = unicode_metadata();
1295
1296        backend
1297            .put_object(&id, &metadata, stream::single("hello, world"))
1298            .await?;
1299
1300        let meta = backend.get_metadata(&id).await?.unwrap();
1301        assert_eq!(meta.filename, metadata.filename);
1302        assert_eq!(meta.custom, metadata.custom);
1303
1304        Ok(())
1305    }
1306
1307    #[tokio::test]
1308    async fn test_unicode_metadata_roundtrip_multipart_upload() -> Result<()> {
1309        let backend = create_test_backend().await?;
1310        let id = make_id();
1311        let metadata = unicode_metadata();
1312
1313        multipart_put(&backend, &id, &metadata, "hello, world").await?;
1314
1315        let meta = backend.get_metadata(&id).await?.unwrap();
1316        assert_eq!(meta.filename, metadata.filename);
1317        assert_eq!(meta.custom, metadata.custom);
1318
1319        Ok(())
1320    }
1321
1322    #[test]
1323    fn from_metadata_uses_provided_time_expires() {
1324        let expires = SystemTime::now() + Duration::from_hours(1);
1325        let metadata = Metadata {
1326            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1327            time_expires: Some(expires),
1328            ..Default::default()
1329        };
1330
1331        let gcs_object = GcsObject::from_metadata(&metadata);
1332        assert_eq!(gcs_object.custom_time, Some(expires));
1333
1334        let roundtripped = gcs_object.into_metadata().unwrap();
1335        assert_eq!(roundtripped.time_expires, Some(expires));
1336    }
1337
1338    #[tokio::test]
1339    async fn test_get_nonexistent() -> Result<()> {
1340        let backend = create_test_backend().await?;
1341
1342        let id = make_id();
1343        let result = backend.get_object(&id, None).await?;
1344        assert!(result.is_none());
1345
1346        Ok(())
1347    }
1348
1349    #[tokio::test]
1350    async fn test_delete_nonexistent() -> Result<()> {
1351        let backend = create_test_backend().await?;
1352
1353        let id = make_id();
1354        backend.delete_object(&id).await?;
1355
1356        Ok(())
1357    }
1358
1359    #[tokio::test]
1360    async fn test_overwrite() -> Result<()> {
1361        let backend = create_test_backend().await?;
1362
1363        let id = make_id();
1364        let metadata = Metadata {
1365            custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
1366            ..Default::default()
1367        };
1368
1369        backend
1370            .put_object(&id, &metadata, stream::single("hello"))
1371            .await?;
1372
1373        let metadata = Metadata {
1374            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1375            ..Default::default()
1376        };
1377
1378        backend
1379            .put_object(&id, &metadata, stream::single("world"))
1380            .await?;
1381
1382        let (meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1383
1384        let payload = stream::read_to_vec(stream).await?;
1385        let str_payload = str::from_utf8(&payload).unwrap();
1386        assert_eq!(str_payload, "world");
1387        assert_eq!(meta.custom, metadata.custom);
1388
1389        Ok(())
1390    }
1391
1392    #[tokio::test]
1393    async fn test_read_after_delete() -> Result<()> {
1394        let backend = create_test_backend().await?;
1395
1396        let id = make_id();
1397        let metadata = Metadata::default();
1398
1399        backend
1400            .put_object(&id, &metadata, stream::single("hello, world"))
1401            .await?;
1402
1403        backend.delete_object(&id).await?;
1404
1405        let result = backend.get_object(&id, None).await?;
1406        assert!(result.is_none());
1407
1408        Ok(())
1409    }
1410
1411    #[tokio::test]
1412    async fn test_ttl_immediate() -> Result<()> {
1413        // NB: We create a TTL that immediately expires in this tests. This might be optimized away
1414        // in a future implementation, so we will have to update this test accordingly.
1415
1416        let backend = create_test_backend().await?;
1417
1418        let id = make_id();
1419        let metadata = Metadata {
1420            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
1421            time_expires: Some(SystemTime::now()),
1422            ..Default::default()
1423        };
1424
1425        backend
1426            .put_object(&id, &metadata, stream::single("hello, world"))
1427            .await?;
1428
1429        let result = backend.get_object(&id, None).await?;
1430        assert!(result.is_none());
1431
1432        Ok(())
1433    }
1434
1435    #[tokio::test]
1436    async fn test_tti_immediate() -> Result<()> {
1437        // NB: We create a TTI that immediately expires in this tests. This might be optimized away
1438        // in a future implementation, so we will have to update this test accordingly.
1439
1440        let backend = create_test_backend().await?;
1441
1442        let id = make_id();
1443        let metadata = Metadata {
1444            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
1445            time_expires: Some(SystemTime::now()),
1446            ..Default::default()
1447        };
1448
1449        backend
1450            .put_object(&id, &metadata, stream::single("hello, world"))
1451            .await?;
1452
1453        let result = backend.get_object(&id, None).await?;
1454        assert!(result.is_none());
1455
1456        Ok(())
1457    }
1458
1459    #[tokio::test]
1460    async fn test_get_metadata_returns_metadata() -> Result<()> {
1461        let backend = create_test_backend().await?;
1462
1463        let id = make_id();
1464        let metadata = Metadata {
1465            content_type: "text/plain".into(),
1466            origin: Some("203.0.113.42".into()),
1467            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1468            ..Default::default()
1469        };
1470
1471        backend
1472            .put_object(&id, &metadata, stream::single("hello, world"))
1473            .await?;
1474
1475        let meta = backend.get_metadata(&id).await?.unwrap();
1476        assert_eq!(meta.content_type, metadata.content_type);
1477        assert_eq!(meta.origin, metadata.origin);
1478        assert_eq!(meta.custom, metadata.custom);
1479
1480        Ok(())
1481    }
1482
1483    #[tokio::test]
1484    async fn test_get_metadata_nonexistent() -> Result<()> {
1485        let backend = create_test_backend().await?;
1486
1487        let id = make_id();
1488        let result = backend.get_metadata(&id).await?;
1489        assert!(result.is_none());
1490
1491        Ok(())
1492    }
1493
1494    #[tokio::test]
1495    async fn test_get_metadata_bumps_tti() -> Result<()> {
1496        let backend = create_test_backend().await?;
1497
1498        let id = make_id();
1499        let tti = Duration::from_hours(2 * 24);
1500        let metadata = Metadata {
1501            content_type: "text/plain".into(),
1502            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1503            time_expires: Some(SystemTime::now() + tti),
1504            ..Default::default()
1505        };
1506
1507        backend
1508            .put_object(&id, &metadata, stream::single("hello, world"))
1509            .await?;
1510
1511        // Backdate custom_time so it falls inside the bump window.
1512        let object_url = backend.object_url(&id)?;
1513        let old_deadline = SystemTime::now() + Duration::from_mins(1);
1514        let (generation, metageneration) =
1515            get_generation_matches(&backend, object_url.clone()).await?;
1516        backend
1517            .update_custom_time(object_url, old_deadline, &generation, &metageneration)
1518            .await?;
1519
1520        // First get_metadata sees the old timestamp and triggers a TTI bump.
1521        let pre_meta = backend.get_metadata(&id).await?.unwrap();
1522        let pre_expiry = pre_meta.time_expires.unwrap();
1523
1524        // Second get_metadata sees the bumped timestamp.
1525        let post_meta = backend.get_metadata(&id).await?.unwrap();
1526        let post_expiry = post_meta.time_expires.unwrap();
1527        assert!(
1528            post_expiry > pre_expiry,
1529            "TTI bump should have extended the expiry: {pre_expiry:?} -> {post_expiry:?}"
1530        );
1531
1532        // Verify the payload is still intact after the bump.
1533        let (_, _, stream) = backend.get_object(&id, None).await?.unwrap();
1534        let payload = stream::read_to_vec(stream).await?;
1535        assert_eq!(&payload, b"hello, world");
1536
1537        Ok(())
1538    }
1539
1540    #[tokio::test]
1541    async fn test_get_metadata_does_not_bump_fresh_tti() -> Result<()> {
1542        let backend = create_test_backend().await?;
1543
1544        let id = make_id();
1545        let tti = Duration::from_hours(2 * 24);
1546        let metadata = Metadata {
1547            content_type: "text/plain".into(),
1548            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1549            time_expires: Some(SystemTime::now() + tti),
1550            ..Default::default()
1551        };
1552
1553        backend
1554            .put_object(&id, &metadata, stream::single("hello, world"))
1555            .await?;
1556
1557        // A freshly written object has time_expires ≈ now + 2d, which is well outside
1558        // the bump window (now + 2d - 1d = now + 1d). No bump should occur.
1559        let first = backend.get_metadata(&id).await?.unwrap();
1560        let first_expiry = first.time_expires.unwrap();
1561
1562        let second = backend.get_metadata(&id).await?.unwrap();
1563        let second_expiry = second.time_expires.unwrap();
1564
1565        assert_eq!(
1566            first_expiry, second_expiry,
1567            "Fresh TTI object should not have its expiry bumped"
1568        );
1569
1570        Ok(())
1571    }
1572
1573    #[tokio::test]
1574    async fn test_short_tti_bumps() -> Result<()> {
1575        let backend = create_test_backend().await?;
1576
1577        let id = make_id();
1578        let tti = Duration::from_hours(2);
1579        let metadata = Metadata {
1580            content_type: "text/plain".into(),
1581            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1582            time_expires: Some(SystemTime::now() + tti),
1583            ..Default::default()
1584        };
1585
1586        backend
1587            .put_object(&id, &metadata, stream::single("hello, world"))
1588            .await?;
1589
1590        // Backdate custom_time so it falls inside the bump window.
1591        let object_url = backend.object_url(&id)?;
1592        let old_deadline = SystemTime::now() + Duration::from_mins(1);
1593        let (generation, metageneration) =
1594            get_generation_matches(&backend, object_url.clone()).await?;
1595        backend
1596            .update_custom_time(object_url, old_deadline, &generation, &metageneration)
1597            .await?;
1598
1599        // First get_metadata triggers the bump.
1600        let pre_meta = backend.get_metadata(&id).await?.unwrap();
1601        let pre_expiry = pre_meta.time_expires.unwrap();
1602
1603        // Second get_metadata sees the bumped timestamp.
1604        let post_meta = backend.get_metadata(&id).await?.unwrap();
1605        let post_expiry = post_meta.time_expires.unwrap();
1606        assert!(
1607            post_expiry > pre_expiry,
1608            "Short TTI bump should have extended the expiry: {pre_expiry:?} -> {post_expiry:?}"
1609        );
1610
1611        Ok(())
1612    }
1613
1614    #[tokio::test]
1615    async fn test_compressed_payload_roundtrip() -> Result<()> {
1616        use objectstore_types::metadata::Compression;
1617
1618        let backend = create_test_backend().await?;
1619
1620        let plaintext = b"hello, world (but compressed with zstd)";
1621        let compressed = zstd::encode_all(&plaintext[..], 3)?;
1622
1623        let id = make_id();
1624        let metadata = Metadata {
1625            content_type: "text/plain".into(),
1626            compression: Some(Compression::Zstd),
1627            ..Default::default()
1628        };
1629
1630        backend
1631            .put_object(&id, &metadata, stream::single(compressed.clone()))
1632            .await?;
1633
1634        let (meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1635        let payload = stream::read_to_vec(stream).await?;
1636
1637        assert_eq!(meta.compression, Some(Compression::Zstd));
1638        assert_eq!(
1639            payload, compressed,
1640            "Payload should be returned still compressed, not auto-decompressed"
1641        );
1642
1643        Ok(())
1644    }
1645
1646    #[tokio::test]
1647    async fn test_multipart_single_part() -> Result<()> {
1648        let backend = create_test_backend().await?;
1649        let id = make_id();
1650        let metadata = Metadata {
1651            content_type: "text/plain".into(),
1652            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_mins(33)),
1653            origin: Some("203.0.113.42".into()),
1654            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1655            ..Default::default()
1656        };
1657
1658        let upload_id = backend.initiate_multipart(&id, &metadata).await?;
1659
1660        let data = b"hello, multipart world!";
1661        let etag = backend
1662            .upload_part(
1663                &id,
1664                &upload_id,
1665                NonZeroU32::new(1).unwrap(),
1666                data.len() as u64,
1667                None,
1668                stream::single(data.to_vec()),
1669            )
1670            .await?;
1671
1672        let result = backend
1673            .complete_multipart(
1674                &id,
1675                &upload_id,
1676                vec![CompletedPart {
1677                    part_number: NonZeroU32::new(1).unwrap(),
1678                    etag,
1679                }],
1680            )
1681            .await?;
1682        assert!(result.is_none(), "expected no error on complete");
1683
1684        let (meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1685        let payload = stream::read_to_vec(stream).await?;
1686        assert_eq!(payload, data);
1687        assert_eq!(meta.content_type, "text/plain".to_string());
1688        assert_eq!(
1689            meta.expiration_policy,
1690            ExpirationPolicy::TimeToLive(Duration::from_mins(33))
1691        );
1692        assert_eq!(meta.origin, Some("203.0.113.42".into()));
1693        assert_eq!(
1694            meta.custom,
1695            BTreeMap::from_iter([("hello".into(), "world".into())])
1696        );
1697
1698        Ok(())
1699    }
1700
1701    #[tokio::test]
1702    async fn test_multipart_multiple_parts() -> Result<()> {
1703        let backend = create_test_backend().await?;
1704        let id = make_id();
1705        let metadata = Metadata::default();
1706
1707        let upload_id = backend.initiate_multipart(&id, &metadata).await?;
1708
1709        // Non-final parts must be >= 5 MiB.
1710        const MIN_PART: usize = 5 * 1024 * 1024;
1711        let part1 = vec![b'a'; MIN_PART];
1712        let part2 = vec![b'b'; MIN_PART];
1713        let part3 = b"cccc".to_vec();
1714
1715        let etag1 = backend
1716            .upload_part(
1717                &id,
1718                &upload_id,
1719                NonZeroU32::new(1).unwrap(),
1720                part1.len() as u64,
1721                None,
1722                stream::single(part1.clone()),
1723            )
1724            .await?;
1725        let etag2 = backend
1726            .upload_part(
1727                &id,
1728                &upload_id,
1729                NonZeroU32::new(2).unwrap(),
1730                part2.len() as u64,
1731                None,
1732                stream::single(part2.clone()),
1733            )
1734            .await?;
1735        let etag3 = backend
1736            .upload_part(
1737                &id,
1738                &upload_id,
1739                NonZeroU32::new(3).unwrap(),
1740                part3.len() as u64,
1741                None,
1742                stream::single(part3.clone()),
1743            )
1744            .await?;
1745
1746        let result = backend
1747            .complete_multipart(
1748                &id,
1749                &upload_id,
1750                vec![
1751                    CompletedPart {
1752                        part_number: NonZeroU32::new(1).unwrap(),
1753                        etag: etag1,
1754                    },
1755                    CompletedPart {
1756                        part_number: NonZeroU32::new(2).unwrap(),
1757                        etag: etag2,
1758                    },
1759                    CompletedPart {
1760                        part_number: NonZeroU32::new(3).unwrap(),
1761                        etag: etag3,
1762                    },
1763                ],
1764            )
1765            .await?;
1766        assert!(result.is_none(), "expected no error on complete");
1767
1768        // Object exists after complete
1769        let (_meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1770        let payload = stream::read_to_vec(stream).await?;
1771        let mut expected = Vec::new();
1772        expected.extend_from_slice(&part1);
1773        expected.extend_from_slice(&part2);
1774        expected.extend_from_slice(&part3);
1775        assert_eq!(payload, expected);
1776
1777        Ok(())
1778    }
1779
1780    #[tokio::test]
1781    async fn test_multipart_out_of_order_upload() -> Result<()> {
1782        let backend = create_test_backend().await?;
1783        let id = make_id();
1784        let metadata = Metadata::default();
1785
1786        let upload_id = backend.initiate_multipart(&id, &metadata).await?;
1787
1788        // Non-final parts must be >= 5 MiB.
1789        const MIN_PART: usize = 5 * 1024 * 1024;
1790        let part1 = vec![b'a'; MIN_PART];
1791        let part2 = vec![b'b'; MIN_PART];
1792        let part3 = b"cccc".to_vec();
1793
1794        // Upload parts out of order: 2, 3, 1.
1795        let etag2 = backend
1796            .upload_part(
1797                &id,
1798                &upload_id,
1799                NonZeroU32::new(2).unwrap(),
1800                part2.len() as u64,
1801                None,
1802                stream::single(part2.clone()),
1803            )
1804            .await?;
1805        let etag3 = backend
1806            .upload_part(
1807                &id,
1808                &upload_id,
1809                NonZeroU32::new(3).unwrap(),
1810                part3.len() as u64,
1811                None,
1812                stream::single(part3.clone()),
1813            )
1814            .await?;
1815        let etag1 = backend
1816            .upload_part(
1817                &id,
1818                &upload_id,
1819                NonZeroU32::new(1).unwrap(),
1820                part1.len() as u64,
1821                None,
1822                stream::single(part1.clone()),
1823            )
1824            .await?;
1825
1826        // Complete with parts listed in order.
1827        let result = backend
1828            .complete_multipart(
1829                &id,
1830                &upload_id,
1831                vec![
1832                    CompletedPart {
1833                        part_number: NonZeroU32::new(1).unwrap(),
1834                        etag: etag1,
1835                    },
1836                    CompletedPart {
1837                        part_number: NonZeroU32::new(2).unwrap(),
1838                        etag: etag2,
1839                    },
1840                    CompletedPart {
1841                        part_number: NonZeroU32::new(3).unwrap(),
1842                        etag: etag3,
1843                    },
1844                ],
1845            )
1846            .await?;
1847        assert!(result.is_none(), "expected no error on complete");
1848
1849        // Verify reassembly order matches part numbers, not upload order.
1850        let (_meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1851        let payload = stream::read_to_vec(stream).await?;
1852        let mut expected = Vec::new();
1853        expected.extend_from_slice(&part1);
1854        expected.extend_from_slice(&part2);
1855        expected.extend_from_slice(&part3);
1856        assert_eq!(payload, expected);
1857
1858        Ok(())
1859    }
1860
1861    #[tokio::test]
1862    async fn test_multipart_list_parts() -> Result<()> {
1863        let backend = create_test_backend().await?;
1864        let id = make_id();
1865        let metadata = Metadata::default();
1866
1867        let upload_id = backend.initiate_multipart(&id, &metadata).await?;
1868
1869        let etag1 = backend
1870            .upload_part(
1871                &id,
1872                &upload_id,
1873                NonZeroU32::new(1).unwrap(),
1874                3,
1875                None,
1876                stream::single(b"aaa".to_vec()),
1877            )
1878            .await?;
1879        let etag2 = backend
1880            .upload_part(
1881                &id,
1882                &upload_id,
1883                NonZeroU32::new(2).unwrap(),
1884                3,
1885                None,
1886                stream::single(b"bbb".to_vec()),
1887            )
1888            .await?;
1889
1890        // List all parts.
1891        let list = backend.list_parts(&id, &upload_id, None, None).await?;
1892        assert_eq!(list.parts.len(), 2);
1893        assert_eq!(list.parts[0].part_number.get(), 1);
1894        assert_eq!(list.parts[0].etag, etag1);
1895        assert_eq!(list.parts[0].size, 3);
1896        assert_eq!(list.parts[1].part_number.get(), 2);
1897        assert_eq!(list.parts[1].etag, etag2);
1898        assert_eq!(list.parts[1].size, 3);
1899
1900        // List with max_parts=1 to test pagination.
1901        let page1 = backend.list_parts(&id, &upload_id, Some(1), None).await?;
1902        assert_eq!(page1.parts.len(), 1);
1903        assert_eq!(page1.parts[0].part_number.get(), 1);
1904        assert!(page1.is_truncated);
1905        assert!(page1.next_part_number_marker.is_some());
1906
1907        let page2 = backend
1908            .list_parts(&id, &upload_id, Some(1), page1.next_part_number_marker)
1909            .await?;
1910        assert_eq!(page2.parts.len(), 1);
1911        assert_eq!(page2.parts[0].part_number.get(), 2);
1912
1913        // Clean up.
1914        backend.abort_multipart(&id, &upload_id).await?;
1915
1916        Ok(())
1917    }
1918
1919    #[tokio::test]
1920    async fn test_multipart_abort() -> Result<()> {
1921        let backend = create_test_backend().await?;
1922        let id = make_id();
1923        let metadata = Metadata::default();
1924
1925        let upload_id = backend.initiate_multipart(&id, &metadata).await?;
1926
1927        backend
1928            .upload_part(
1929                &id,
1930                &upload_id,
1931                NonZeroU32::new(1).unwrap(),
1932                5,
1933                None,
1934                stream::single(b"hello".to_vec()),
1935            )
1936            .await?;
1937
1938        backend.abort_multipart(&id, &upload_id).await?;
1939
1940        // Object should not exist after abort.
1941        let result = backend.get_object(&id, None).await?;
1942        assert!(result.is_none(), "object should not exist after abort");
1943
1944        // A second abort should still succeed (idempotent 404 handling).
1945        backend.abort_multipart(&id, &upload_id).await?;
1946
1947        Ok(())
1948    }
1949
1950    async fn multipart_put(
1951        backend: &GcsBackend,
1952        id: &ObjectId,
1953        metadata: &Metadata,
1954        payload: impl Into<bytes::Bytes>,
1955    ) -> Result<()> {
1956        let payload: bytes::Bytes = payload.into();
1957        let upload_id = backend.initiate_multipart(id, metadata).await?;
1958        let etag = backend
1959            .upload_part(
1960                id,
1961                &upload_id,
1962                NonZeroU32::new(1).unwrap(),
1963                payload.len() as u64,
1964                None,
1965                stream::single(payload),
1966            )
1967            .await?;
1968        let error = backend
1969            .complete_multipart(
1970                id,
1971                &upload_id,
1972                vec![CompletedPart {
1973                    part_number: NonZeroU32::new(1).unwrap(),
1974                    etag,
1975                }],
1976            )
1977            .await?;
1978        assert!(
1979            error.is_none(),
1980            "complete_multipart returned error: {error:?}"
1981        );
1982        Ok(())
1983    }
1984
1985    #[tokio::test]
1986    async fn test_multipart_ttl_immediate() -> Result<()> {
1987        let backend = create_test_backend().await?;
1988        let id = make_id();
1989        let metadata = Metadata {
1990            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
1991            time_expires: Some(SystemTime::now()),
1992            ..Default::default()
1993        };
1994
1995        multipart_put(&backend, &id, &metadata, "hello, world").await?;
1996
1997        let result = backend.get_object(&id, None).await?;
1998        assert!(result.is_none());
1999
2000        Ok(())
2001    }
2002
2003    #[tokio::test]
2004    async fn test_multipart_tti_immediate() -> Result<()> {
2005        let backend = create_test_backend().await?;
2006        let id = make_id();
2007        let metadata = Metadata {
2008            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
2009            time_expires: Some(SystemTime::now()),
2010            ..Default::default()
2011        };
2012
2013        multipart_put(&backend, &id, &metadata, "hello, world").await?;
2014
2015        let result = backend.get_object(&id, None).await?;
2016        assert!(result.is_none());
2017
2018        Ok(())
2019    }
2020
2021    #[tokio::test]
2022    async fn test_multipart_compressed_payload_roundtrip() -> Result<()> {
2023        use objectstore_types::metadata::Compression;
2024
2025        let backend = create_test_backend().await?;
2026
2027        let plaintext = b"hello, world (but compressed with zstd)";
2028        let compressed = zstd::encode_all(&plaintext[..], 3)?;
2029
2030        let id = make_id();
2031        let metadata = Metadata {
2032            content_type: "text/plain".into(),
2033            compression: Some(Compression::Zstd),
2034            ..Default::default()
2035        };
2036
2037        multipart_put(&backend, &id, &metadata, compressed.clone()).await?;
2038
2039        let (meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
2040        let payload = stream::read_to_vec(stream).await?;
2041
2042        assert_eq!(meta.compression, Some(Compression::Zstd));
2043        assert_eq!(
2044            payload, compressed,
2045            "Payload should be returned still compressed, not auto-decompressed"
2046        );
2047
2048        Ok(())
2049    }
2050}