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