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::num::NonZeroU64;
7use std::sync::Arc;
8use std::time::Duration;
9use std::{fmt, io};
10
11use futures_util::{StreamExt, TryStreamExt};
12use gcp_auth::TokenProvider;
13use objectstore_types::headers;
14use objectstore_types::metadata::{ExpirationPolicy, Metadata};
15use objectstore_types::range::{ByteRange, ContentRange};
16use objectstore_types::time::{Rfc3339Timestamp, Timestamp};
17use reqwest::header::HeaderMap;
18use reqwest::{Body, IntoUrl, Method, RequestBuilder, StatusCode, Url, header, multipart};
19use serde::{Deserialize, Serialize};
20
21use crate::backend::common::{
22    self, Backend, DeleteResponse, ExpiryUpdate, GetResponse, MetadataResponse, PutResponse,
23    SetExpiryResponse,
24};
25use crate::backend::extensions::{ReqwestResultExt, ResponseExt, SendTraced};
26use crate::change_stream::{
27    ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
28};
29use crate::error::{Error, ErrorKind, Result, ResultExt as _};
30use crate::gcp_auth::PrefetchingTokenProvider;
31use crate::id::ObjectId;
32use crate::resumable::{BackendToken, Session, UploadProgress};
33use crate::stream::ClientStream;
34
35/// Configuration for [`GcsBackend`].
36///
37/// Stores objects in [Google Cloud Storage]. Authentication uses Application Default Credentials
38/// (ADC), which can be provided via the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or
39/// the GCE/GKE metadata service.
40///
41/// **Note**: The bucket must be pre-created with the following lifecycle policy:
42/// - `daysSinceCustomTime`: 1 day
43/// - `action`: delete
44///
45/// [Google Cloud Storage]: https://cloud.google.com/storage
46///
47/// # Example
48///
49/// ```yaml
50/// storage:
51///   type: gcs
52///   bucket: objectstore-bucket
53/// ```
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct GcsConfig {
56    /// Optional custom GCS endpoint URL.
57    ///
58    /// Useful for testing with emulators. If `None`, uses the default GCS endpoint.
59    ///
60    /// # Default
61    ///
62    /// `None` (uses default GCS endpoint)
63    ///
64    /// # Environment Variables
65    ///
66    /// - `OS__STORAGE__TYPE=gcs`
67    /// - `OS__STORAGE__ENDPOINT=http://localhost:9000` (optional)
68    pub endpoint: Option<String>,
69
70    /// GCS bucket name.
71    ///
72    /// The bucket must exist before starting the server.
73    ///
74    /// # Environment Variables
75    ///
76    /// - `OS__STORAGE__BUCKET=my-gcs-bucket`
77    pub bucket: String,
78
79    /// Maximum time TCP data may remain unacknowledged or blocked before failing the request.
80    ///
81    /// This detects stalled transports. It does not limit the total upload duration or the time
82    /// waiting for a response after the upload is acknowledged. Applied on Linux and ignored on
83    /// other platforms. `0s` uses the operating system's default.
84    ///
85    /// # Default
86    ///
87    /// `30s`
88    ///
89    /// # Environment Variables
90    ///
91    /// - `OS__STORAGE__TCP_USER_TIMEOUT=30s`
92    #[serde(default = "common::default_tcp_user_timeout", with = "humantime_serde")]
93    pub tcp_user_timeout: Duration,
94
95    /// Reports what this backend stores, for per-usecase cost attribution.
96    ///
97    /// # Default
98    ///
99    /// `None`, which disables reporting for this backend.
100    ///
101    /// # Environment Variables
102    ///
103    /// - `OS__STORAGE__COGS__SHARED_RESOURCE_ID=gcs_objectstore`
104    /// - `OS__STORAGE__COGS__SAMPLE_RATE=1.0` (optional)
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub cogs: Option<CostTrackerStreamConfig>,
107}
108
109/// Response header carrying the size GCS stored, in bytes.
110///
111/// Differs from `Content-Length`, which describes the transfer, once an object is
112/// content-encoded.
113const STORED_CONTENT_LENGTH: &str = "x-goog-stored-content-length";
114
115/// Reads how many payload bytes GCS stored for an object, consuming the response.
116///
117/// Prefers the [`STORED_CONTENT_LENGTH`] response header. If it is missing or malformed, falls back
118/// to the `size` of the [`GcsObject`] in the response body. Returns `None` if neither source yields
119/// a size.
120///
121/// Either way the body is read to the end, so reqwest can return the connection to its pool.
122async fn read_stored_content_length(response: reqwest::Response) -> Option<u64> {
123    let header = response
124        .headers()
125        .get(STORED_CONTENT_LENGTH)
126        .and_then(|value| value.to_str().ok())
127        .and_then(|value| value.parse().ok());
128
129    if let Some(size) = header {
130        response.drain_body().await;
131        return Some(size);
132    }
133
134    response.json::<GcsObject>().await.ok()?.size?.parse().ok()
135}
136
137/// Default endpoint used to access the GCS JSON API.
138const DEFAULT_ENDPOINT: &str = "https://storage.googleapis.com";
139/// Permission scopes required for accessing GCS.
140const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/devstorage.read_write"];
141/// How many times to retry failed operations.
142const REQUEST_RETRY_COUNT: usize = 2;
143
144/// Prefix for our built-in metadata stored in GCS metadata field
145const BUILTIN_META_PREFIX: &str = "x-sn-";
146/// Prefix for user custom metadata stored in GCS metadata field
147const CUSTOM_META_PREFIX: &str = "x-snme-";
148
149/// GCS object resource.
150///
151/// This is the representation of the object resource in GCS JSON API without its payload. Where no
152/// dedicated fields are available, we encode both built-in and custom metadata in the `metadata`
153/// field.
154#[derive(Debug, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156struct GcsObject {
157    /// Content-Type of the object data. If an object is stored without a Content-Type, it is served
158    /// as application/octet-stream.
159    pub content_type: Cow<'static, str>,
160
161    /// Content encoding, used to store [`Metadata::compression`].
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub content_encoding: Option<String>,
164
165    /// Custom time stamp used for time-based expiration.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub custom_time: Option<Rfc3339Timestamp>,
168
169    /// The `Content-Length` of the data in bytes. GCS returns this as a string.
170    ///
171    /// GCS sets this in metadata responses. We can use it to know the size of an object
172    /// without having to stream it.
173    pub size: Option<String>,
174
175    /// Timestamp of when this object was created.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub time_created: Option<Rfc3339Timestamp>,
178
179    /// User-provided metadata, including our built-in metadata.
180    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
181    pub metadata: BTreeMap<GcsMetaKey, String>,
182
183    /// Version of the object's contents.
184    #[serde(skip_serializing)]
185    pub generation: String,
186
187    /// Version of the object's metadata.
188    #[serde(skip_serializing)]
189    pub metageneration: String,
190}
191
192impl GcsObject {
193    /// Bytes this object's custom metadata occupies, keys included.
194    ///
195    /// GCS stores metadata alongside the payload, so it counts toward an object's size.
196    fn metadata_size(&self) -> u64 {
197        self.metadata
198            .iter()
199            .filter(|(key, _)| !matches!(key, GcsMetaKey::EmulatorIgnored))
200            .map(|(key, value)| key.to_string().len() as u64 + value.len() as u64)
201            .sum()
202    }
203
204    /// Returns `true` if the object is expired at the given access time.
205    pub fn is_expired(&self, access_time: Timestamp) -> bool {
206        match self.custom_time {
207            Some(expires_at) => access_time > expires_at.into_inner(),
208            None => false,
209        }
210    }
211
212    /// Returns the generation and metageneration of this object.
213    pub fn generations(&self) -> GcsGenerations<'_> {
214        (&self.generation, &self.metageneration)
215    }
216
217    /// Converts our Metadata type to GCS JSON object metadata.
218    pub fn from_metadata(metadata: &Metadata) -> Self {
219        let mut gcs_object = GcsObject {
220            content_type: metadata.content_type.clone(),
221            size: metadata.size.map(|size| size.to_string()),
222            content_encoding: None,
223            custom_time: None,
224            time_created: metadata.time_created.map(Timestamp::as_rfc3339),
225            metadata: BTreeMap::new(),
226            generation: String::new(),
227            metageneration: String::new(),
228        };
229
230        // For time-based expiration, set the `customTime` field. The bucket must have a
231        // `daysSinceCustomTime` lifecycle rule configured to delete objects with this field set.
232        // This rule automatically skips objects without `customTime` set.
233        gcs_object.custom_time = metadata.time_expires.map(Timestamp::as_rfc3339);
234
235        if let Some(compression) = metadata.compression {
236            gcs_object.content_encoding = Some(compression.to_string());
237        }
238
239        if metadata.expiration_policy != ExpirationPolicy::default() {
240            gcs_object.metadata.insert(
241                GcsMetaKey::Expiration,
242                metadata.expiration_policy.to_string(),
243            );
244        }
245
246        // Preserve the stored escaping convention for free-form metadata strings.
247        if let Some(origin) = &metadata.origin {
248            gcs_object.metadata.insert(
249                GcsMetaKey::Origin,
250                headers::encode_header_str(origin).into(),
251            );
252        }
253
254        if let Some(filename) = &metadata.filename {
255            gcs_object.metadata.insert(
256                GcsMetaKey::Filename,
257                headers::encode_header_str(filename).into(),
258            );
259        }
260
261        for (key, value) in &metadata.custom {
262            gcs_object.metadata.insert(
263                GcsMetaKey::Custom(key.clone()),
264                headers::encode_header_str(value).into(),
265            );
266        }
267
268        gcs_object
269    }
270
271    /// Converts GCS JSON object metadata to our Metadata type.
272    pub fn into_metadata(mut self) -> Result<Metadata> {
273        // Remove ignored metadata keys that are set by the GCS emulator.
274        self.metadata.remove(&GcsMetaKey::EmulatorIgnored);
275
276        let expiration_policy = self
277            .metadata
278            .remove(&GcsMetaKey::Expiration)
279            .map(|s| s.parse())
280            .transpose()
281            .context(ErrorKind::CorruptData, "decoding GCS expiration policy")?
282            .unwrap_or_default();
283
284        let origin = self
285            .metadata
286            .remove(&GcsMetaKey::Origin)
287            .map(|value| decode_gcs_meta_value(&value, "decoding GCS origin metadata"))
288            .transpose()?;
289        let filename = self
290            .metadata
291            .remove(&GcsMetaKey::Filename)
292            .map(|value| decode_gcs_meta_value(&value, "decoding GCS filename metadata"))
293            .transpose()?;
294
295        let content_type = self.content_type;
296        let compression = self
297            .content_encoding
298            .map(|s| s.parse())
299            .transpose()
300            .context(ErrorKind::CorruptData, "decoding GCS compression")?;
301        let size = self
302            .size
303            .map(|size| size.parse())
304            .transpose()
305            .context(ErrorKind::CorruptData, "decoding GCS object size")?;
306        let time_created = self.time_created.map(Rfc3339Timestamp::into_inner);
307
308        // At this point, all built-in metadata should have been removed from self.metadata.
309        let mut custom = BTreeMap::new();
310        for (key, value) in self.metadata {
311            if let GcsMetaKey::Custom(custom_key) = key {
312                custom.insert(
313                    custom_key,
314                    decode_gcs_meta_value(&value, "decoding GCS custom metadata")?,
315                );
316            } else {
317                return Err(Error::new(
318                    ErrorKind::CorruptData,
319                    format!("unexpected GCS metadata key: {key}"),
320                ));
321            }
322        }
323
324        Ok(Metadata {
325            content_type,
326            expiration_policy,
327            compression,
328            origin,
329            filename,
330            size,
331            custom,
332            time_created,
333            time_expires: self.custom_time.map(Rfc3339Timestamp::into_inner),
334        })
335    }
336}
337
338/// The object and metadata versions of a GCS object, used for conditional updates.
339type GcsGenerations<'a> = (&'a str, &'a str);
340
341/// Key for [`GcsObject::metadata`].
342#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
343enum GcsMetaKey {
344    /// Built-in metadata key for [`Metadata::expiration_policy`].
345    Expiration,
346    /// Built-in metadata key for [`Metadata::origin`].
347    Origin,
348    /// Built-in metadata key for [`Metadata::filename`].
349    Filename,
350    /// Ignored metadata set by the GCS emulator.
351    EmulatorIgnored,
352    /// User-defined custom metadata key.
353    Custom(String),
354}
355
356impl std::str::FromStr for GcsMetaKey {
357    type Err = anyhow::Error;
358
359    fn from_str(s: &str) -> Result<Self, Self::Err> {
360        if s.starts_with("x_emulator_") || s.starts_with("x_testbench_") {
361            return Ok(GcsMetaKey::EmulatorIgnored);
362        }
363
364        Ok(match s.strip_prefix(BUILTIN_META_PREFIX) {
365            Some("expiration") => GcsMetaKey::Expiration,
366            Some("origin") => GcsMetaKey::Origin,
367            Some("filename") => GcsMetaKey::Filename,
368            Some(unknown) => anyhow::bail!("unknown builtin metadata key: {unknown}"),
369            None => match s.strip_prefix(CUSTOM_META_PREFIX) {
370                Some(key) => GcsMetaKey::Custom(key.to_string()),
371                None => anyhow::bail!("invalid GCS metadata key format: {s}"),
372            },
373        })
374    }
375}
376
377impl fmt::Display for GcsMetaKey {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        match self {
380            Self::Expiration => write!(f, "{BUILTIN_META_PREFIX}expiration"),
381            Self::Origin => write!(f, "{BUILTIN_META_PREFIX}origin"),
382            Self::Filename => write!(f, "{BUILTIN_META_PREFIX}filename"),
383            Self::EmulatorIgnored => unreachable!("do not serialize emulator metadata"),
384            Self::Custom(key) => write!(f, "{CUSTOM_META_PREFIX}{key}"),
385        }
386    }
387}
388
389impl<'de> Deserialize<'de> for GcsMetaKey {
390    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391    where
392        D: serde::Deserializer<'de>,
393    {
394        let s = Cow::<'de, str>::deserialize(deserializer)?;
395        s.parse().map_err(serde::de::Error::custom)
396    }
397}
398
399impl Serialize for GcsMetaKey {
400    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
401    where
402        S: serde::Serializer,
403    {
404        serializer.collect_str(self)
405    }
406}
407
408/// Decodes a stored GCS metadata value into its logical string.
409fn decode_gcs_meta_value(value: &str, context: &'static str) -> Result<String> {
410    headers::decode_header_str(value).context(ErrorKind::CorruptData, context)
411}
412
413/// Special status code returned by GCS when a Resumable Upload is canceled successfully or when
414/// making other requests to a session that was recently canceled.
415const CLIENT_CLOSED_REQUEST_STATUS: u16 = 499;
416
417enum GcsUploadProgress {
418    Incomplete(u64),
419    Complete(GcsObject),
420}
421
422impl From<GcsUploadProgress> for UploadProgress {
423    fn from(value: GcsUploadProgress) -> Self {
424        match value {
425            GcsUploadProgress::Incomplete(offset) => UploadProgress::Incomplete { offset },
426            GcsUploadProgress::Complete(_) => UploadProgress::Complete,
427        }
428    }
429}
430
431/// Returns `true` if the error is a transient backend failure worth retrying.
432fn error_is_retryable(error: &Error) -> bool {
433    matches!(
434        error.kind(),
435        ErrorKind::BackendRateLimited | ErrorKind::BackendTimeout | ErrorKind::BackendUnavailable
436    )
437}
438
439/// GCS JSON API backend for long-term storage of large objects.
440pub struct GcsBackend {
441    client: reqwest::Client,
442    endpoint: Url,
443    bucket: String,
444    token_provider: Option<PrefetchingTokenProvider>,
445
446    change_stream: Arc<dyn ChangeStream>,
447}
448
449impl GcsBackend {
450    /// Creates an authenticated GCS JSON API backend bound to the bucket in `config`.
451    pub async fn new(config: GcsConfig, streams: &ChangeStreamFactory) -> anyhow::Result<Self> {
452        let GcsConfig {
453            endpoint,
454            bucket,
455            tcp_user_timeout,
456            cogs,
457        } = config;
458        let change_stream = streams.build(cogs.as_ref());
459
460        let token_provider = if endpoint.is_none() {
461            Some(PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?)
462        } else {
463            None
464        };
465
466        let endpoint_str = endpoint.as_deref().unwrap_or(DEFAULT_ENDPOINT);
467
468        Ok(Self {
469            client: common::reqwest_client(tcp_user_timeout),
470            endpoint: endpoint_str
471                .parse()
472                .map_err(|e| anyhow::Error::new(e).context("invalid GCS endpoint URL"))?,
473            bucket,
474            token_provider,
475            change_stream,
476        })
477    }
478
479    /// Formats the GCS object (metadata) URL for the given key.
480    fn object_url(&self, id: &ObjectId) -> Result<Url> {
481        let mut url = self.endpoint.clone();
482
483        let path = id.as_storage_path().to_string();
484        url.path_segments_mut()
485            .map_err(|()| {
486                Error::new(
487                    ErrorKind::Internal,
488                    format!("building GCS object URL from {}", self.endpoint),
489                )
490            })?
491            .extend(&["storage", "v1", "b", &self.bucket, "o", &path]);
492
493        Ok(url)
494    }
495
496    /// Formats the GCS upload URL for the given upload type.
497    fn upload_url(&self, id: &ObjectId, upload_type: &str) -> Result<Url> {
498        let mut url = self.endpoint.clone();
499
500        url.path_segments_mut()
501            .map_err(|()| {
502                Error::new(
503                    ErrorKind::Internal,
504                    format!("building GCS object URL from {}", self.endpoint),
505                )
506            })?
507            .extend(&["upload", "storage", "v1", "b", &self.bucket, "o"]);
508
509        url.query_pairs_mut()
510            .append_pair("uploadType", upload_type)
511            .append_pair("name", &id.as_storage_path().to_string());
512
513        Ok(url)
514    }
515
516    /// Creates a request builder with the appropriate authentication.
517    async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
518        let mut builder = self.client.request(method, url);
519        if let Some(provider) = &self.token_provider {
520            let token = provider.token(TOKEN_SCOPES).await.context(
521                ErrorKind::BackendFailure,
522                "getting GCS authentication token",
523            )?;
524            builder = builder.bearer_auth(token.as_str());
525        }
526        Ok(builder)
527    }
528
529    /// Counts a terminal request-operation error without retrying the future.
530    async fn with_failure_metrics<T>(
531        &self,
532        action: &'static str,
533        future: impl Future<Output = Result<T>> + Send,
534    ) -> Result<T> {
535        future.await.inspect_err(|_| {
536            objectstore_metrics::count!("gcs.failures", action = action);
537        })
538    }
539
540    /// Retries a GCS request on transient errors.
541    async fn with_retry<T, F>(&self, action: &'static str, f: impl Fn() -> F) -> Result<T>
542    where
543        F: Future<Output = Result<T>> + Send,
544    {
545        let mut retry_count = 0usize;
546        loop {
547            match f().await {
548                Ok(res) => return Ok(res),
549                Err(ref e) if retry_count < REQUEST_RETRY_COUNT && error_is_retryable(e) => {
550                    retry_count += 1;
551                    objectstore_metrics::count!("gcs.retries", action = action);
552                    objectstore_log::warn!(!!e, retry_count, action, "Retrying request");
553                }
554                Err(e) => {
555                    objectstore_metrics::count!("gcs.failures", action = action);
556                    return Err(e);
557                }
558            }
559        }
560    }
561
562    /// Fetches GCS object metadata without modifying the object.
563    #[tracing::instrument(level = "debug", fields(%object_url), skip(self))]
564    async fn get_gcs_metadata(
565        &self,
566        object_url: &Url,
567        access_time: Timestamp,
568    ) -> Result<Option<GcsObject>> {
569        let metadata_opt = self
570            .with_retry("get_metadata", || async {
571                let resp = self
572                    .request(Method::GET, object_url.clone())
573                    .await?
574                    .send_traced()
575                    .await
576                    .reqwest_context("getting GCS object metadata")?;
577
578                if resp.status() == StatusCode::NOT_FOUND {
579                    resp.drain_body().await;
580                    return Ok(None);
581                }
582
583                let metadata: GcsObject = resp
584                    .check_error("getting GCS object metadata")
585                    .await?
586                    .json()
587                    .await
588                    .reqwest_context("getting GCS object metadata")?;
589
590                Ok(Some(metadata))
591            })
592            .await?;
593
594        let Some(gcs_metadata) = metadata_opt else {
595            objectstore_log::debug!("Object not found");
596            return Ok(None);
597        };
598
599        // Filter already expired objects but leave them to garbage collection
600        if gcs_metadata.is_expired(access_time) {
601            objectstore_log::debug!("Object found but past expiry");
602            return Ok(None);
603        }
604
605        Ok(Some(gcs_metadata))
606    }
607
608    /// Moves an object's `customTime` and optionally updates its metadata in the same PATCH.
609    ///
610    /// Lifecycle expiry is anchored to `customTime`; TTL updates accompany that deadline.
611    ///
612    /// Returns the expiry update outcome, distinguishing absence from a conflict.
613    #[tracing::instrument(level = "debug", fields(%object_url), skip(self))]
614    async fn update_custom_time(
615        &self,
616        object_url: Url,
617        custom_time: Timestamp,
618        generations: GcsGenerations<'_>,
619        metadata: Option<BTreeMap<GcsMetaKey, String>>,
620    ) -> Result<SetExpiryResponse> {
621        #[derive(Debug, Serialize)]
622        #[serde(rename_all = "camelCase")]
623        struct CustomTimeRequest {
624            custom_time: Rfc3339Timestamp,
625            #[serde(skip_serializing_if = "Option::is_none")]
626            metadata: Option<BTreeMap<GcsMetaKey, String>>,
627        }
628
629        let mut object_url = object_url;
630        object_url
631            .query_pairs_mut()
632            .append_pair("ifGenerationMatch", generations.0)
633            .append_pair("ifMetagenerationMatch", generations.1);
634
635        let request = CustomTimeRequest {
636            custom_time: custom_time.as_rfc3339(),
637            metadata,
638        };
639
640        self.with_retry("update_custom_time", || async {
641            let response = self
642                .request(Method::PATCH, object_url.clone())
643                .await?
644                .json(&request)
645                .send_traced()
646                .await
647                .reqwest_context("updating GCS custom time")?;
648
649            let outcome = match response.status() {
650                StatusCode::NOT_FOUND => Some(SetExpiryResponse::NotFound),
651                StatusCode::PRECONDITION_FAILED => Some(SetExpiryResponse::Rejected),
652                _ => None,
653            };
654            if let Some(outcome) = outcome {
655                response.drain_body().await;
656                return Ok(outcome);
657            }
658
659            response
660                .check_error("updating GCS custom time")
661                .await?
662                .drain_body()
663                .await;
664
665            Ok(SetExpiryResponse::Satisfied(custom_time))
666        })
667        .await
668    }
669
670    /// Reports a committed object write to the [`ChangeStream`].
671    async fn report_object_write(
672        &self,
673        id: &ObjectId,
674        stored_size: Option<u64>,
675        metadata_size: u64,
676        expires_at: Option<Timestamp>,
677    ) {
678        match stored_size {
679            Some(stored_size) => {
680                self.change_stream
681                    .commit_write(id, stored_size + metadata_size, expires_at)
682                    .await
683            }
684            None => {
685                objectstore_metrics::count!("change_stream.unreported", reason = "no_stored_size")
686            }
687        }
688    }
689}
690
691impl fmt::Debug for GcsBackend {
692    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693        f.debug_struct("GcsJsonApi")
694            .field("endpoint", &self.endpoint)
695            .field("bucket", &self.bucket)
696            .finish_non_exhaustive()
697    }
698}
699
700/// Converts GCS's inclusive `Range: bytes=0-N` acknowledgement into the next offset.
701fn range_header_to_offset(value: &str, upload_length: NonZeroU64) -> Result<u64> {
702    let end = value.strip_prefix("bytes=0-").ok_or_else(|| {
703        Error::new(
704            ErrorKind::Internal,
705            "malformed GCS Range header for resumable progress",
706        )
707    })?;
708    let end = end.parse::<u64>().context(
709        ErrorKind::Internal,
710        "invalid GCS Range header for resumable progress",
711    )?;
712
713    let offset = end
714        .checked_add(1)
715        .ok_or_else(|| Error::new(ErrorKind::Internal, "GCS offset overflows u64"))?;
716    if offset > upload_length.get() {
717        return Err(Error::new(
718            ErrorKind::Internal,
719            "GCS offset exceeds upload length",
720        ));
721    }
722    Ok(offset)
723}
724
725/// Interprets a Resumable Upload response, shared by chunk writes and status queries.
726///
727/// Returns the progress GCS reported, plus the completed object when this is the response that
728/// finished the upload.
729async fn range_response_to_upload_progress(
730    upload_length: NonZeroU64,
731    response: reqwest::Response,
732) -> Result<GcsUploadProgress> {
733    let status = response.status();
734
735    match status {
736        StatusCode::NOT_FOUND => {
737            response.drain_body().await;
738            return Err(ErrorKind::UnknownUploadSession.into());
739        }
740        status if status == StatusCode::GONE || status.as_u16() == CLIENT_CLOSED_REQUEST_STATUS => {
741            response.drain_body().await;
742            return Err(ErrorKind::UploadSessionGone.into());
743        }
744        _ => {}
745    }
746
747    let response = response
748        .check_error("processing a GCS resumable upload response")
749        .await?;
750
751    match status {
752        StatusCode::OK | StatusCode::CREATED => {
753            let body = response
754                .bytes()
755                .await
756                .reqwest_context("reading a completed GCS resumable upload response")?;
757            let object = serde_json::from_slice::<GcsObject>(&body).context(
758                ErrorKind::CorruptData,
759                "parsing a completed GCS resumable upload response",
760            )?;
761            Ok(GcsUploadProgress::Complete(object))
762        }
763        // GCS calls it "308 Resume Incomplete"
764        StatusCode::PERMANENT_REDIRECT => {
765            let offset = match response.headers().get(header::RANGE) {
766                Some(range) => {
767                    let range = range.to_str().map_err(|_| {
768                        Error::new(
769                            ErrorKind::BackendFailure,
770                            "invalid GCS resumable upload Range header",
771                        )
772                    })?;
773                    range_header_to_offset(range, upload_length)?
774                }
775                // GCS omits this header while it holds nothing
776                None => 0,
777            };
778            response.drain_body().await;
779            Ok(GcsUploadProgress::Incomplete(offset))
780        }
781        _ => {
782            response.drain_body().await;
783            Err(Error::new(
784                ErrorKind::BackendFailure,
785                format!("unexpected GCS resumable upload status {status}"),
786            ))
787        }
788    }
789}
790
791#[async_trait::async_trait]
792impl Backend for GcsBackend {
793    fn name(&self) -> &'static str {
794        "gcs"
795    }
796
797    fn upload_granularity(&self) -> u64 {
798        256 * 1024
799    }
800
801    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
802    async fn put_object(
803        &self,
804        id: &ObjectId,
805        metadata: &Metadata,
806        stream: ClientStream,
807        _access_time: Timestamp,
808    ) -> Result<PutResponse> {
809        objectstore_log::debug!("Writing to GCS backend");
810        self.change_stream
811            .begin_write(id, metadata.time_expires)
812            .await?;
813        let gcs_metadata = GcsObject::from_metadata(metadata);
814
815        // NB: Ensure the order of these fields and that a content-type is attached to them. Both
816        // are required by the GCS API.
817        let metadata_json = serde_json::to_string(&gcs_metadata)
818            .context(ErrorKind::Internal, "encoding GCS upload metadata")?;
819
820        let content_type = metadata
821            .content_type
822            .parse()
823            .context(ErrorKind::InvalidMetadata, "encoding GCS content type")?;
824
825        let multipart = multipart::Form::new()
826            .part(
827                "metadata",
828                multipart::Part::text(metadata_json)
829                    .mime_str("application/json")
830                    .expect("application/json is a valid mime type"),
831            )
832            .part(
833                "media",
834                multipart::Part::stream(Body::wrap_stream(stream.boxed()))
835                    // Ensure the content type goes into headers unchanged including case
836                    // sensitivity, as required by GCS. This means we cannot use `mime_str`.
837                    .headers(HeaderMap::from_iter([(header::CONTENT_TYPE, content_type)])),
838            );
839
840        // GCS requires a multipart/related request. Its body looks identical to
841        // multipart/form-data, but the Content-Type header is different. Hence, we have to manually
842        // set the header *after* writing the multipart form into the request.
843        let content_type = format!("multipart/related; boundary={}", multipart.boundary());
844
845        let url = self.upload_url(id, "multipart")?;
846        let response = self
847            .with_failure_metrics("put_object", async {
848                self.request(Method::POST, url)
849                    .await?
850                    .multipart(multipart)
851                    .header(header::CONTENT_TYPE, content_type)
852                    .send_traced()
853                    .await
854                    .check_error("uploading a GCS object")
855                    .await
856            })
857            .await?;
858
859        let stored_size = read_stored_content_length(response).await;
860        self.report_object_write(
861            id,
862            stored_size,
863            gcs_metadata.metadata_size(),
864            metadata.time_expires,
865        )
866        .await;
867
868        Ok(())
869    }
870
871    #[tracing::instrument(level = "debug", skip(self))]
872    async fn get_object(
873        &self,
874        id: &ObjectId,
875        access_time: Timestamp,
876        range: Option<ByteRange>,
877    ) -> Result<GetResponse> {
878        objectstore_log::debug!("Reading from GCS backend");
879        let object_url = self.object_url(id)?;
880        let mut generation_retry_count = 0usize;
881
882        loop {
883            let Some(gcs_metadata) = self.get_gcs_metadata(&object_url, access_time).await? else {
884                return Ok(None);
885            };
886
887            let mut download_url = object_url.clone();
888            download_url
889                .query_pairs_mut()
890                .append_pair("alt", "media")
891                .append_pair("ifGenerationMatch", &gcs_metadata.generation);
892
893            let payload_response = self
894                .with_retry("get_payload", || async {
895                    // GCS transparently decompresses objects stored with `Content-Encoding: gzip`
896                    // unless the request accepts gzip (decompressive transcoding). We always return
897                    // payloads exactly as stored, so opt out of it. This also keeps range reads
898                    // applying to the stored bytes. See:
899                    // https://cloud.google.com/storage/docs/transcoding#decompressive_transcoding
900                    let mut req = self
901                        .request(Method::GET, download_url.clone())
902                        .await?
903                        .header(header::ACCEPT_ENCODING, "gzip");
904                    if let Some(r) = range {
905                        req = req.header(header::RANGE, r.to_header_value());
906                    }
907
908                    let resp = req
909                        .send_traced()
910                        .await
911                        .reqwest_context("getting a GCS object payload")?;
912
913                    if resp.status() == StatusCode::PRECONDITION_FAILED {
914                        resp.drain_body().await;
915                        return Ok(None);
916                    }
917
918                    if resp.status() == StatusCode::RANGE_NOT_SATISFIABLE {
919                        let raw = resp
920                            .headers()
921                            .get(header::CONTENT_RANGE)
922                            .and_then(|v| v.to_str().ok());
923                        let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
924                        let err = match total {
925                            Some(total) => ErrorKind::RangeNotSatisfiable { total }.into(),
926                            None => Error::new(
927                                ErrorKind::BackendFailure,
928                                "invalid GCS 416 Content-Range",
929                            ),
930                        };
931                        resp.drain_body().await;
932                        return Err(err);
933                    }
934
935                    resp.check_error("getting a GCS object payload")
936                        .await
937                        .map(Some)
938                })
939                .await?;
940
941            let Some(payload_response) = payload_response else {
942                if generation_retry_count >= REQUEST_RETRY_COUNT {
943                    objectstore_metrics::count!("gcs.failures", action = "get_object");
944                    return Err(Error::new(
945                        ErrorKind::BackendFailure,
946                        "GCS object kept changing while being read",
947                    ));
948                }
949
950                generation_retry_count += 1;
951                objectstore_metrics::count!("gcs.retries", action = "get_object");
952                continue;
953            };
954
955            let content_range = if payload_response.status() == StatusCode::PARTIAL_CONTENT {
956                Some(
957                    payload_response
958                        .headers()
959                        .get(header::CONTENT_RANGE)
960                        .and_then(|v| v.to_str().ok())
961                        .and_then(|s| s.parse::<ContentRange>().ok())
962                        .ok_or_else(|| {
963                            Error::new(ErrorKind::BackendFailure, "missing GCS 206 Content-Range")
964                        })?,
965                )
966            } else {
967                None
968            };
969
970            let stream = payload_response
971                .bytes_stream()
972                .map_err(io::Error::other)
973                .boxed();
974
975            let metadata = gcs_metadata.into_metadata()?;
976            return Ok(Some((metadata, content_range, stream)));
977        }
978    }
979
980    #[tracing::instrument(level = "debug", skip(self))]
981    async fn get_metadata(
982        &self,
983        id: &ObjectId,
984        access_time: Timestamp,
985    ) -> Result<MetadataResponse> {
986        objectstore_log::debug!("Reading metadata from GCS backend");
987        let object_url = self.object_url(id)?;
988        match self.get_gcs_metadata(&object_url, access_time).await? {
989            Some(gcs_metadata) => Ok(Some(gcs_metadata.into_metadata()?)),
990            None => Ok(None),
991        }
992    }
993
994    #[tracing::instrument(level = "debug", skip(self))]
995    async fn set_expiry(
996        &self,
997        id: &ObjectId,
998        target: ExpiryUpdate,
999        access_time: Timestamp,
1000    ) -> Result<SetExpiryResponse> {
1001        let object_url = self.object_url(id)?;
1002        let Some(object) = self.get_gcs_metadata(&object_url, access_time).await? else {
1003            return Ok(SetExpiryResponse::NotFound);
1004        };
1005        let Some(current_expiry) = object.custom_time else {
1006            return Ok(SetExpiryResponse::Rejected);
1007        };
1008        let time_created = object.time_created.map(Rfc3339Timestamp::into_inner);
1009        let Some(expire_at) = target.resolve(time_created, access_time)? else {
1010            return Ok(SetExpiryResponse::Rejected);
1011        };
1012        if current_expiry.into_inner() >= expire_at {
1013            return Ok(SetExpiryResponse::Satisfied(expire_at)); // already satisfied
1014        }
1015
1016        let policy = object
1017            .metadata
1018            .get(&GcsMetaKey::Expiration)
1019            .map(|value| value.parse())
1020            .transpose()
1021            .context(ErrorKind::CorruptData, "decoding GCS expiration policy")?
1022            .unwrap_or_default();
1023        let updated_policy = common::extended_expiration_policy(
1024            policy,
1025            time_created,
1026            current_expiry.into_inner(),
1027            expire_at,
1028        )?;
1029        let metadata = if updated_policy != policy {
1030            Some(BTreeMap::from([(
1031                GcsMetaKey::Expiration,
1032                updated_policy.to_string(),
1033            )]))
1034        } else {
1035            None
1036        };
1037
1038        self.change_stream.begin_update(id, Some(expire_at)).await?;
1039        let outcome = self
1040            .update_custom_time(object_url, expire_at, object.generations(), metadata)
1041            .await?;
1042        if matches!(outcome, SetExpiryResponse::Satisfied(_)) {
1043            self.change_stream.commit_update(id, Some(expire_at)).await;
1044        }
1045
1046        Ok(outcome)
1047    }
1048
1049    #[tracing::instrument(level = "debug", skip(self))]
1050    async fn delete_object(
1051        &self,
1052        id: &ObjectId,
1053        _access_time: Timestamp,
1054    ) -> Result<DeleteResponse> {
1055        objectstore_log::debug!("Deleting from GCS backend");
1056        let object_url = self.object_url(id)?;
1057
1058        let deleted = self
1059            .with_retry("delete", || async {
1060                let resp = self
1061                    .request(Method::DELETE, object_url.clone())
1062                    .await?
1063                    .send_traced()
1064                    .await
1065                    .reqwest_context("deleting a GCS object")?;
1066
1067                // Do not error for objects that do not exist
1068                if resp.status() == StatusCode::NOT_FOUND {
1069                    resp.drain_body().await;
1070                    return Ok(false);
1071                }
1072
1073                resp.check_error("deleting a GCS object")
1074                    .await?
1075                    .drain_body()
1076                    .await;
1077
1078                Ok(true)
1079            })
1080            .await?;
1081
1082        if deleted {
1083            self.change_stream.commit_delete(id).await;
1084        }
1085
1086        Ok(())
1087    }
1088
1089    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
1090    async fn create_upload_session(
1091        &self,
1092        id: &ObjectId,
1093        metadata: &Metadata,
1094        upload_length: NonZeroU64,
1095    ) -> Result<Option<BackendToken>> {
1096        objectstore_log::debug!("Creating resumable upload session on GCS backend");
1097        self.change_stream
1098            .begin_write(id, metadata.time_expires)
1099            .await?;
1100        let url = self.upload_url(id, "resumable")?;
1101        let metadata_json = serde_json::to_vec(&GcsObject::from_metadata(metadata)).context(
1102            ErrorKind::Internal,
1103            "serializing GCS resumable upload metadata",
1104        )?;
1105        let content_type = metadata.content_type.clone();
1106
1107        let location = self
1108            .with_retry("create_resumable_upload", || {
1109                let url = url.clone();
1110                let metadata_json = metadata_json.clone();
1111                let content_type = content_type.clone();
1112                async move {
1113                    let response = self
1114                        .request(Method::POST, url)
1115                        .await?
1116                        .header(header::CONTENT_TYPE, "application/json")
1117                        .header("x-upload-content-type", content_type.as_ref())
1118                        .header("x-upload-content-length", upload_length.get())
1119                        .body(metadata_json)
1120                        .send_traced()
1121                        .await
1122                        .check_error("creating a GCS resumable upload")
1123                        .await?;
1124
1125                    if response.status() != StatusCode::OK {
1126                        let status = response.status();
1127                        response.drain_body().await;
1128                        return Err(Error::new(
1129                            ErrorKind::BackendFailure,
1130                            format!("unexpected GCS resumable upload creation status {status}"),
1131                        ));
1132                    }
1133
1134                    let location = response
1135                        .headers()
1136                        .get(header::LOCATION)
1137                        .and_then(|value| value.to_str().ok())
1138                        .map(str::to_owned)
1139                        .ok_or_else(|| {
1140                            Error::new(
1141                                ErrorKind::BackendFailure,
1142                                "missing valid Location header in GCS resumable upload creation response",
1143                            )
1144                        })?;
1145                    response.drain_body().await;
1146                    Ok(location)
1147                }
1148            })
1149            .await?;
1150
1151        let session_uri = Url::parse(&location).map_err(|_| {
1152            Error::new(
1153                ErrorKind::BackendFailure,
1154                "invalid Location URL in GCS resumable upload creation response",
1155            )
1156        })?;
1157        Ok(Some(session_uri.into()))
1158    }
1159
1160    #[tracing::instrument(level = "debug", fields(?session, offset, content_length), skip_all)]
1161    async fn put_chunk(
1162        &self,
1163        session: &Session,
1164        offset: u64,
1165        content_length: u64,
1166        stream: ClientStream,
1167    ) -> Result<UploadProgress> {
1168        objectstore_log::debug!("Uploading resumable chunk to GCS backend");
1169        let session_uri =
1170            Url::parse(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
1171
1172        let end = offset
1173            .checked_add(content_length)
1174            .filter(|end| *end <= session.upload_length.get())
1175            .ok_or(ErrorKind::ChunkExceedsUploadLength {
1176                offset,
1177                content_length,
1178                upload_length: session.upload_length.get(),
1179            })?;
1180        let granularity = self.upload_granularity();
1181        if content_length > 0 && content_length < granularity && end != session.upload_length.get()
1182        {
1183            return Err(ErrorKind::ChunkTooSmall {
1184                chunk_length: content_length,
1185                upload_granularity: granularity,
1186            }
1187            .into());
1188        }
1189
1190        let content_range = match content_length {
1191            // An empty chunk is equivalent to an offset query.
1192            0 => format!("bytes */{}", session.upload_length),
1193            _ => format!("bytes {offset}-{}/{}", end - 1, session.upload_length),
1194        };
1195
1196        let progress = self
1197            .with_failure_metrics("put_chunk", async {
1198                let response = self
1199                    .request(Method::PUT, session_uri.as_str())
1200                    .await?
1201                    .header(header::CONTENT_LENGTH, content_length)
1202                    .header(header::CONTENT_RANGE, content_range)
1203                    .body(Body::wrap_stream(stream))
1204                    .send_traced()
1205                    .await
1206                    .reqwest_context("uploading a GCS resumable chunk")?;
1207
1208                range_response_to_upload_progress(session.upload_length, response).await
1209            })
1210            .await?;
1211        if let GcsUploadProgress::Complete(ref object) = progress {
1212            let stored_size = object.size.as_deref().and_then(|size| size.parse().ok());
1213            let expires_at = object.custom_time.map(Rfc3339Timestamp::into_inner);
1214            self.report_object_write(
1215                &session.object_id,
1216                stored_size,
1217                object.metadata_size(),
1218                expires_at,
1219            )
1220            .await;
1221        }
1222        Ok(progress.into())
1223    }
1224
1225    #[tracing::instrument(level = "debug", fields(?session), skip_all)]
1226    async fn upload_offset(&self, session: &Session) -> Result<UploadProgress> {
1227        objectstore_log::debug!("Querying resumable upload offset on GCS backend");
1228        let session_uri =
1229            Url::parse(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
1230
1231        self.with_retry("query_resumable_upload", || async {
1232            let response = self
1233                .request(Method::PUT, session_uri.as_str())
1234                .await?
1235                .header(header::CONTENT_LENGTH, 0)
1236                .header(
1237                    header::CONTENT_RANGE,
1238                    format!("bytes */{}", session.upload_length),
1239                )
1240                .send_traced()
1241                .await
1242                .reqwest_context("querying a GCS resumable upload")?;
1243
1244            let progress =
1245                range_response_to_upload_progress(session.upload_length, response).await?;
1246            // The final `put_chunk` may have persisted the object but failed while
1247            // reading its response, so completion observed here must be reported too.
1248            if let GcsUploadProgress::Complete(ref object) = progress {
1249                let stored_size = object.size.as_deref().and_then(|size| size.parse().ok());
1250                let expires_at = object.custom_time.map(Rfc3339Timestamp::into_inner);
1251                self.report_object_write(
1252                    &session.object_id,
1253                    stored_size,
1254                    object.metadata_size(),
1255                    expires_at,
1256                )
1257                .await;
1258            }
1259            Ok(progress.into())
1260        })
1261        .await
1262    }
1263
1264    #[tracing::instrument(level = "debug", fields(?session), skip_all)]
1265    async fn cancel_upload(&self, session: &Session) -> Result<()> {
1266        objectstore_log::debug!("Cancelling resumable upload on GCS backend");
1267        let session_uri =
1268            Url::parse(&session.backend_token).map_err(|_| ErrorKind::UnknownUploadSession)?;
1269        self.with_retry("cancel_resumable_upload", || {
1270            let session_uri = session_uri.clone();
1271            async move {
1272                let response = self
1273                    .request(Method::DELETE, session_uri)
1274                    .await?
1275                    .header(header::CONTENT_LENGTH, 0)
1276                    .send_traced()
1277                    .await
1278                    .reqwest_context("canceling a GCS resumable upload")?;
1279                match response.status() {
1280                    // Expected status code when canceling a recently created upload.
1281                    status if status.as_u16() == CLIENT_CLOSED_REQUEST_STATUS => {
1282                        response.drain_body().await;
1283                        Ok(())
1284                    }
1285                    // The upload was already canceled or never existed.
1286                    StatusCode::GONE | StatusCode::NOT_FOUND => {
1287                        response.drain_body().await;
1288                        Ok(())
1289                    }
1290                    _ => {
1291                        response
1292                            .check_error("canceling a GCS resumable upload")
1293                            .await?
1294                            .drain_body()
1295                            .await;
1296                        Err(Error::new(
1297                            ErrorKind::BackendFailure,
1298                            "unexpected GCS resumable upload cancellation status",
1299                        ))
1300                    }
1301                }
1302            }
1303        })
1304        .await
1305    }
1306
1307    async fn join(&self) {
1308        flush_change_stream(&self.change_stream).await;
1309    }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use std::collections::BTreeMap;
1315    use std::io::{Read, Write};
1316    use std::net::{TcpListener, TcpStream};
1317    use std::num::NonZeroU64;
1318    use std::sync::mpsc;
1319    use std::thread;
1320    use std::time::Duration;
1321
1322    use anyhow::Result;
1323    #[cfg(feature = "storage-cogs")]
1324    use objectstore_inventory_tracker::{OpType, test_utils::DummyProducer};
1325    use objectstore_types::scope::{Scope, Scopes};
1326    use reqwest::header::{HeaderMap, HeaderValue};
1327
1328    use super::*;
1329    use crate::backend::common::ExpiryTarget;
1330    use crate::id::ObjectContext;
1331    use crate::stream;
1332    #[cfg(feature = "storage-cogs")]
1333    use crate::stream::ClientError;
1334
1335    impl GcsBackend {
1336        async fn create_upload_session(
1337            &self,
1338            id: &ObjectId,
1339            metadata: &Metadata,
1340            upload_length: NonZeroU64,
1341        ) -> Result<Session> {
1342            let backend_token =
1343                <Self as Backend>::create_upload_session(self, id, metadata, upload_length)
1344                    .await?
1345                    .ok_or_else(|| Error::from(ErrorKind::Unsupported))?;
1346            Ok(Session {
1347                object_id: id.clone(),
1348                upload_length,
1349                backend_token,
1350            })
1351        }
1352    }
1353
1354    fn read_http_request(connection: &mut TcpStream) -> String {
1355        let mut bytes = Vec::new();
1356        let mut byte = [0];
1357        while !bytes.ends_with(b"\r\n\r\n") {
1358            connection.read_exact(&mut byte).unwrap();
1359            bytes.push(byte[0]);
1360        }
1361        String::from_utf8(bytes).unwrap()
1362    }
1363
1364    fn write_http_response(
1365        connection: &mut TcpStream,
1366        status: &str,
1367        content_type: &str,
1368        body: &str,
1369    ) {
1370        write!(
1371            connection,
1372            "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1373            body.len()
1374        )
1375        .unwrap();
1376    }
1377
1378    fn start_generation_race_server()
1379    -> (String, mpsc::Receiver<Vec<String>>, thread::JoinHandle<()>) {
1380        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1381        let endpoint = format!("http://{}", listener.local_addr().unwrap());
1382        let (request_tx, request_rx) = mpsc::channel();
1383        let server = thread::spawn(move || {
1384            let responses = [
1385                (
1386                    "200 OK",
1387                    "application/json",
1388                    r#"{"contentType":"text/old","generation":"1","metageneration":"1"}"#,
1389                ),
1390                ("412 Precondition Failed", "text/plain", "stale generation"),
1391                (
1392                    "200 OK",
1393                    "application/json",
1394                    r#"{"contentType":"text/new","generation":"2","metageneration":"1"}"#,
1395                ),
1396                ("200 OK", "text/plain", "new"),
1397            ];
1398            let mut requests = Vec::new();
1399
1400            for (status, content_type, body) in responses {
1401                let (mut connection, _) = listener.accept().unwrap();
1402                requests.push(read_http_request(&mut connection));
1403                write_http_response(&mut connection, status, content_type, body);
1404            }
1405
1406            request_tx.send(requests).unwrap();
1407        });
1408        (endpoint, request_rx, server)
1409    }
1410
1411    const RESUMABLE_CHUNK_SIZE: usize = 256 * 1024;
1412
1413    // NB: Not run any of these tests, you need to have a GCS emulator running. This is done
1414    // automatically in CI.
1415    //
1416    // Refer to the readme for how to set up the emulator.
1417
1418    fn test_config() -> GcsConfig {
1419        GcsConfig {
1420            endpoint: Some("http://localhost:8087".into()),
1421            bucket: "test-bucket".into(),
1422            tcp_user_timeout: Duration::from_secs(30),
1423            cogs: None,
1424        }
1425    }
1426
1427    async fn create_test_backend() -> Result<GcsBackend> {
1428        GcsBackend::new(test_config(), &ChangeStreamFactory::default()).await
1429    }
1430
1431    #[cfg(feature = "storage-cogs")]
1432    async fn create_test_backend_with_change_stream() -> Result<(GcsBackend, DummyProducer)> {
1433        let (streams, producer) = crate::change_stream::dummy_factory();
1434        let config = GcsConfig {
1435            cogs: Some(CostTrackerStreamConfig {
1436                shared_resource_id: "gcs_objectstore".into(),
1437                sample_rate: 1.0,
1438            }),
1439            ..test_config()
1440        };
1441
1442        Ok((GcsBackend::new(config, &streams).await?, producer))
1443    }
1444
1445    #[derive(Deserialize)]
1446    struct RetryTestResource {
1447        id: String,
1448    }
1449
1450    /// Configures one storage-testbench failure and sends its ID on subsequent backend requests.
1451    async fn inject_retry_test(
1452        backend: &mut GcsBackend,
1453        method: &str,
1454        instruction: &str,
1455    ) -> Result<()> {
1456        let retry_test: RetryTestResource = reqwest::Client::new()
1457            .post(backend.endpoint.join("retry_test")?)
1458            .json(&serde_json::json!({
1459                "instructions": { method: [instruction] },
1460                "transport": "HTTP",
1461            }))
1462            .send()
1463            .await?
1464            .error_for_status()?
1465            .json()
1466            .await?;
1467
1468        let mut headers = HeaderMap::new();
1469        headers.insert("x-retry-test-id", HeaderValue::from_str(&retry_test.id)?);
1470        backend.client = reqwest::Client::builder()
1471            .default_headers(headers)
1472            .build()?;
1473        Ok(())
1474    }
1475
1476    fn make_id() -> ObjectId {
1477        ObjectId::random(ObjectContext {
1478            usecase: "testing".parse().unwrap(),
1479            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1480        })
1481    }
1482
1483    fn make_id_with_key(key: &str) -> ObjectId {
1484        ObjectId::new(
1485            ObjectContext {
1486                usecase: "testing".parse().unwrap(),
1487                scopes: Scopes::from_iter([
1488                    Scope::create("organization", "42").unwrap(),
1489                    Scope::create("project", "7").unwrap(),
1490                ]),
1491            },
1492            key.into(),
1493        )
1494    }
1495
1496    fn nonzero(value: u64) -> NonZeroU64 {
1497        NonZeroU64::new(value).unwrap()
1498    }
1499
1500    #[tokio::test]
1501    async fn get_object_retries_from_metadata_after_generation_conflict() -> Result<()> {
1502        let (endpoint, request_rx, server) = start_generation_race_server();
1503        let backend = GcsBackend::new(
1504            GcsConfig {
1505                endpoint: Some(endpoint),
1506                bucket: "bucket".into(),
1507                tcp_user_timeout: Duration::from_secs(30),
1508                cogs: None,
1509            },
1510            &ChangeStreamFactory::default(),
1511        )
1512        .await?;
1513
1514        let (metadata, _, stream) = backend
1515            .get_object(&make_id(), Timestamp::now(), None)
1516            .await?
1517            .unwrap();
1518        assert_eq!(metadata.content_type, "text/new");
1519        assert_eq!(stream::read_to_vec(stream).await?, b"new");
1520
1521        let requests = request_rx.recv().unwrap();
1522        assert_eq!(requests.len(), 4);
1523        assert!(!requests[0].contains("alt=media"));
1524        assert!(requests[1].contains("alt=media&ifGenerationMatch=1"));
1525        assert!(!requests[2].contains("alt=media"));
1526        assert!(requests[3].contains("alt=media&ifGenerationMatch=2"));
1527        // Payload downloads opt out of GCS decompressive transcoding.
1528        for request in [&requests[1], &requests[3]] {
1529            assert!(
1530                request
1531                    .to_ascii_lowercase()
1532                    .contains("\r\naccept-encoding: gzip\r\n"),
1533                "missing accept-encoding: {request}"
1534            );
1535        }
1536        server.join().unwrap();
1537
1538        Ok(())
1539    }
1540
1541    #[test]
1542    fn resumable_range_reports_next_offset_and_rejects_malformed_values() -> Result<()> {
1543        assert_eq!(range_header_to_offset("bytes=0-0", nonzero(10))?, 1);
1544        assert_eq!(
1545            range_header_to_offset("bytes=0-262143", nonzero(300_000))?,
1546            262_144
1547        );
1548
1549        for malformed in [
1550            "",
1551            "bytes=1-2",
1552            "bytes=0-",
1553            "bytes=0-*",
1554            "bytes=0-9 ",
1555            "bytes=0-9,bytes=20-30",
1556        ] {
1557            assert!(
1558                range_header_to_offset(malformed, nonzero(100)).is_err(),
1559                "accepted {malformed:?}"
1560            );
1561        }
1562        assert_eq!(range_header_to_offset("bytes=0-9", nonzero(10))?, 10);
1563        assert!(range_header_to_offset("bytes=0-18446744073709551615", nonzero(u64::MAX)).is_err());
1564        Ok(())
1565    }
1566
1567    #[tokio::test]
1568    async fn test_resumable_empty_chunk_reports_offset_without_writing() -> Result<()> {
1569        let backend = create_test_backend().await?;
1570        let id = make_id_with_key("resumable-empty-chunk");
1571        let upload_length = 2 * (RESUMABLE_CHUNK_SIZE + 2);
1572        let token = backend
1573            .create_upload_session(&id, &Metadata::default(), nonzero(upload_length as u64))
1574            .await?;
1575
1576        // An empty chunk cannot advance a session that still expects bytes. It reports the
1577        // authoritative offset instead of failing, and leaves what GCS holds untouched.
1578        assert_eq!(
1579            backend
1580                .put_chunk(&token, 0, 0, stream::single(Vec::new()))
1581                .await?,
1582            UploadProgress::Incomplete { offset: 0 }
1583        );
1584        // The backend rejects undersized non-final chunks, so use an unaligned chunk larger
1585        // than one granularity unit to exercise the emulator's alignment behavior.
1586        let chunk_length = RESUMABLE_CHUNK_SIZE + 2;
1587        let after_write = backend
1588            .put_chunk(
1589                &token,
1590                0,
1591                chunk_length as u64,
1592                stream::single(vec![b'a'; chunk_length]),
1593            )
1594            .await?;
1595        assert!(matches!(after_write, UploadProgress::Incomplete { .. }));
1596
1597        // Whichever prefix GCS acknowledged, an empty chunk reports that same position rather
1598        // than moving it. GCS documents that a chunk "should be a multiple of 256 KiB ... unless
1599        // it's the last chunk", and that a client "should not assume that the server received all
1600        // bytes sent in any given request". The emulator acknowledges the unaligned tail, so the
1601        // position itself is not asserted here.
1602        assert_eq!(
1603            backend
1604                .put_chunk(&token, chunk_length as u64, 0, stream::single(Vec::new()))
1605                .await?,
1606            after_write
1607        );
1608        assert_eq!(backend.upload_offset(&token).await?, after_write);
1609        Ok(())
1610    }
1611
1612    #[tokio::test]
1613    async fn test_resumable_single_and_multi_chunk_uploads() -> Result<()> {
1614        let backend = create_test_backend().await?;
1615
1616        let single_id = make_id_with_key("resumable-single");
1617        let single = b"single chunk".to_vec();
1618        let token = backend
1619            .create_upload_session(
1620                &single_id,
1621                &Metadata::default(),
1622                nonzero(single.len() as u64),
1623            )
1624            .await?;
1625        assert_eq!(
1626            backend
1627                .put_chunk(
1628                    &token,
1629                    0,
1630                    single.len() as u64,
1631                    stream::single(single.clone()),
1632                )
1633                .await?,
1634            UploadProgress::Complete
1635        );
1636        let (_, _, payload) = backend
1637            .get_object(&single_id, Timestamp::now(), None)
1638            .await?
1639            .unwrap();
1640        assert_eq!(stream::read_to_vec(payload).await?, single);
1641
1642        let multi_id = make_id_with_key("resumable-multi");
1643        let mut expected = vec![b'a'; RESUMABLE_CHUNK_SIZE];
1644        expected.extend_from_slice(b"final");
1645        let token = backend
1646            .create_upload_session(
1647                &multi_id,
1648                &Metadata::default(),
1649                nonzero(expected.len() as u64),
1650            )
1651            .await?;
1652        assert_eq!(
1653            backend.upload_offset(&token).await?,
1654            UploadProgress::Incomplete { offset: 0 }
1655        );
1656        let error = backend
1657            .put_chunk(&token, 0, 1, stream::single(b"a".to_vec()))
1658            .await
1659            .unwrap_err();
1660        assert_eq!(
1661            error.kind(),
1662            ErrorKind::ChunkTooSmall {
1663                chunk_length: 1,
1664                upload_granularity: RESUMABLE_CHUNK_SIZE as u64,
1665            }
1666        );
1667        assert_eq!(
1668            backend
1669                .put_chunk(
1670                    &token,
1671                    0,
1672                    RESUMABLE_CHUNK_SIZE as u64,
1673                    stream::single(expected[..RESUMABLE_CHUNK_SIZE].to_vec()),
1674                )
1675                .await?,
1676            UploadProgress::Incomplete {
1677                offset: RESUMABLE_CHUNK_SIZE as u64
1678            }
1679        );
1680        assert_eq!(
1681            backend.upload_offset(&token).await?,
1682            UploadProgress::Incomplete {
1683                offset: RESUMABLE_CHUNK_SIZE as u64
1684            }
1685        );
1686        assert_eq!(
1687            backend
1688                .put_chunk(
1689                    &token,
1690                    RESUMABLE_CHUNK_SIZE as u64,
1691                    5,
1692                    stream::single(b"final".to_vec()),
1693                )
1694                .await?,
1695            UploadProgress::Complete
1696        );
1697        let (_, _, payload) = backend
1698            .get_object(&multi_id, Timestamp::now(), None)
1699            .await?
1700            .unwrap();
1701        assert_eq!(stream::read_to_vec(payload).await?, expected);
1702        Ok(())
1703    }
1704
1705    #[tokio::test]
1706    async fn test_resumable_unaligned_chunk_and_rewind_preserve_persisted_bytes() -> Result<()> {
1707        let backend = create_test_backend().await?;
1708        let id = make_id_with_key("resumable-rewind");
1709        let upload_length = RESUMABLE_CHUNK_SIZE + 3;
1710        let token = backend
1711            .create_upload_session(&id, &Metadata::default(), nonzero(upload_length as u64))
1712            .await?;
1713
1714        let prefix = vec![b'a'; RESUMABLE_CHUNK_SIZE];
1715        assert_eq!(
1716            backend
1717                .put_chunk(&token, 0, prefix.len() as u64, stream::single(prefix))
1718                .await?,
1719            UploadProgress::Incomplete {
1720                offset: RESUMABLE_CHUNK_SIZE as u64
1721            }
1722        );
1723
1724        // Resend three already-persisted positions with different bytes. GCS ignores that overlap
1725        // without comparing it, then appends the suffix from its authoritative offset.
1726        assert_eq!(
1727            backend
1728                .put_chunk(
1729                    &token,
1730                    (RESUMABLE_CHUNK_SIZE - 3) as u64,
1731                    6,
1732                    stream::single(b"BADxyz".to_vec()),
1733                )
1734                .await?,
1735            UploadProgress::Complete
1736        );
1737
1738        let (_, _, payload) = backend
1739            .get_object(&id, Timestamp::now(), None)
1740            .await?
1741            .unwrap();
1742        let payload = stream::read_to_vec(payload).await?;
1743        assert_eq!(&payload[RESUMABLE_CHUNK_SIZE - 3..], b"aaaxyz");
1744        Ok(())
1745    }
1746
1747    #[tokio::test]
1748    async fn test_resumable_rejects_oversized_chunks() -> Result<()> {
1749        let backend = create_test_backend().await?;
1750        let id = make_id_with_key("resumable-validation");
1751        let token = backend
1752            .create_upload_session(&id, &Metadata::default(), nonzero(10))
1753            .await?;
1754
1755        let error = backend
1756            .put_chunk(&token, 8, 3, stream::single(b"abc".to_vec()))
1757            .await
1758            .unwrap_err();
1759        assert_eq!(
1760            error.kind(),
1761            ErrorKind::ChunkExceedsUploadLength {
1762                offset: 8,
1763                content_length: 3,
1764                upload_length: 10
1765            }
1766        );
1767
1768        let error = backend
1769            .put_chunk(&token, u64::MAX, 2, stream::single(b"ab".to_vec()))
1770            .await
1771            .unwrap_err();
1772        assert!(matches!(
1773            error.kind(),
1774            ErrorKind::ChunkExceedsUploadLength { .. }
1775        ));
1776        Ok(())
1777    }
1778
1779    #[tokio::test]
1780    async fn test_resumable_cancel_is_idempotent_and_session_is_not_found() -> Result<()> {
1781        let backend = create_test_backend().await?;
1782        let id = make_id_with_key("resumable-cancel");
1783        let token = backend
1784            .create_upload_session(&id, &Metadata::default(), nonzero(10))
1785            .await?;
1786
1787        backend.cancel_upload(&token).await?;
1788        backend.cancel_upload(&token).await?;
1789        assert!(matches!(
1790            backend.upload_offset(&token).await,
1791            Err(error) if error.kind() == ErrorKind::UnknownUploadSession
1792        ));
1793        Ok(())
1794    }
1795
1796    #[tokio::test]
1797    async fn test_resumable_retries_only_replayable_operations() -> Result<()> {
1798        let mut backend = create_test_backend().await?;
1799        inject_retry_test(&mut backend, "storage.objects.insert", "return-503").await?;
1800        let id = make_id_with_key("resumable-retries");
1801
1802        // Creation consumes the injected 503 and succeeds on the backend's retry.
1803        let token = backend
1804            .create_upload_session(&id, &Metadata::default(), nonzero(10))
1805            .await?;
1806
1807        inject_retry_test(&mut backend, "storage.objects.insert", "return-503").await?;
1808        assert_eq!(
1809            backend.upload_offset(&token).await?,
1810            UploadProgress::Incomplete { offset: 0 }
1811        );
1812
1813        inject_retry_test(&mut backend, "storage.objects.delete", "return-503").await?;
1814        backend.cancel_upload(&token).await?;
1815        Ok(())
1816    }
1817
1818    #[tokio::test]
1819    async fn test_resumable_stream_failures_require_explicit_offset_recovery() -> Result<()> {
1820        // A failure before persistence is returned directly: put_chunk must not retry the body.
1821        let mut backend = create_test_backend().await?;
1822        let id = make_id_with_key("resumable-failure-before");
1823        let token = backend
1824            .create_upload_session(&id, &Metadata::default(), nonzero(4))
1825            .await?;
1826        inject_retry_test(&mut backend, "storage.objects.insert", "return-503").await?;
1827        let metrics = objectstore_metrics::with_capturing_test_client_async(async {
1828            assert!(matches!(
1829                backend
1830                    .put_chunk(&token, 0, 4, stream::single(b"data".to_vec()))
1831                    .await,
1832                Err(error) if error.kind() == ErrorKind::BackendUnavailable
1833            ));
1834        })
1835        .await;
1836        assert_eq!(
1837            metrics
1838                .iter()
1839                .filter(|metric| metric.starts_with("gcs.failures:"))
1840                .collect::<Vec<_>>(),
1841            ["gcs.failures:+1|c|#action:put_chunk"]
1842        );
1843        assert!(
1844            !metrics
1845                .iter()
1846                .any(|metric| metric.starts_with("gcs.retries:"))
1847        );
1848        assert_eq!(
1849            backend.upload_offset(&token).await?,
1850            UploadProgress::Incomplete { offset: 0 }
1851        );
1852
1853        // The emulator persists the first KiB, then fails. Recovery observes that prefix and the
1854        // caller resumes exactly from the returned offset.
1855        let mut backend = create_test_backend().await?;
1856        let id = make_id_with_key("resumable-failure-partial");
1857        let data = vec![b'p'; 2048];
1858        let token = backend
1859            .create_upload_session(&id, &Metadata::default(), nonzero(data.len() as u64))
1860            .await?;
1861        inject_retry_test(
1862            &mut backend,
1863            "storage.objects.insert",
1864            "return-503-after-1K",
1865        )
1866        .await?;
1867        assert!(matches!(
1868            backend
1869                .put_chunk(
1870                    &token,
1871                    0,
1872                    data.len() as u64,
1873                    stream::single(data.clone()),
1874                )
1875                .await,
1876            Err(error) if error.kind() == ErrorKind::BackendUnavailable
1877        ));
1878        assert_eq!(
1879            backend.upload_offset(&token).await?,
1880            UploadProgress::Incomplete { offset: 1024 }
1881        );
1882        assert_eq!(
1883            backend
1884                .put_chunk(&token, 1024, 1024, stream::single(data[1024..].to_vec()))
1885                .await?,
1886            UploadProgress::Complete
1887        );
1888
1889        // GCS persists the final bytes, but storage-testbench truncates the successful JSON
1890        // response. The chunk is not retried; an explicit status query observes completion.
1891        let mut backend = create_test_backend().await?;
1892        let id = make_id_with_key("resumable-failure-final");
1893        let token = backend
1894            .create_upload_session(&id, &Metadata::default(), nonzero(5))
1895            .await?;
1896        inject_retry_test(
1897            &mut backend,
1898            "storage.objects.insert",
1899            "return-broken-stream-final-chunk-after-0B",
1900        )
1901        .await?;
1902        assert!(matches!(
1903            backend
1904                .put_chunk(&token, 0, 5, stream::single(b"final".to_vec()))
1905                .await,
1906            Err(error) if error.kind() == ErrorKind::CorruptData
1907        ));
1908        assert_eq!(
1909            backend.upload_offset(&token).await?,
1910            UploadProgress::Complete
1911        );
1912        Ok(())
1913    }
1914
1915    async fn get_gcs_generations(
1916        backend: &GcsBackend,
1917        object_url: Url,
1918    ) -> Result<(String, String)> {
1919        Ok(backend
1920            .request(Method::GET, object_url)
1921            .await?
1922            .send_traced()
1923            .await
1924            .check_error("getting GCS object metadata")
1925            .await?
1926            .json::<GcsObject>()
1927            .await
1928            .context(
1929                ErrorKind::BackendFailure,
1930                "decoding GCS object metadata response",
1931            )
1932            .map(|object| (object.generation, object.metageneration))?)
1933    }
1934
1935    #[tokio::test]
1936    async fn test_roundtrip() -> Result<()> {
1937        let backend = create_test_backend().await?;
1938
1939        let id = make_id();
1940        let metadata = Metadata {
1941            content_type: "text/plain".into(),
1942            expiration_policy: ExpirationPolicy::Manual,
1943            compression: None,
1944            origin: Some("203.0.113.42".into()),
1945            filename: Some("hello.txt".into()),
1946            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1947            time_created: Some(Timestamp::now()),
1948            time_expires: None,
1949            size: None,
1950        };
1951
1952        backend
1953            .put_object(
1954                &id,
1955                &metadata,
1956                stream::single("hello, world"),
1957                Timestamp::now(),
1958            )
1959            .await?;
1960
1961        let (meta, _, stream) = backend
1962            .get_object(&id, Timestamp::now(), None)
1963            .await?
1964            .unwrap();
1965
1966        let payload = stream::read_to_vec(stream).await?;
1967        let str_payload = str::from_utf8(&payload).unwrap();
1968        assert_eq!(str_payload, "hello, world");
1969        assert_eq!(meta.content_type, metadata.content_type);
1970        assert_eq!(meta.origin, metadata.origin);
1971        assert_eq!(meta.filename, metadata.filename);
1972        assert_eq!(meta.custom, metadata.custom);
1973        assert!(metadata.time_created.is_some());
1974
1975        Ok(())
1976    }
1977
1978    /// Metadata with a non-ASCII filename and custom metadata value.
1979    fn unicode_metadata() -> Metadata {
1980        Metadata {
1981            filename: Some("réport-📄.pdf".into()),
1982            custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
1983            ..Default::default()
1984        }
1985    }
1986
1987    #[tokio::test]
1988    async fn test_unicode_metadata_roundtrip_json_upload() -> Result<()> {
1989        let backend = create_test_backend().await?;
1990        let id = make_id();
1991        let metadata = unicode_metadata();
1992
1993        backend
1994            .put_object(
1995                &id,
1996                &metadata,
1997                stream::single("hello, world"),
1998                Timestamp::now(),
1999            )
2000            .await?;
2001
2002        let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
2003        assert_eq!(meta.filename, metadata.filename);
2004        assert_eq!(meta.custom, metadata.custom);
2005
2006        Ok(())
2007    }
2008
2009    #[test]
2010    fn from_metadata_uses_provided_time_expires() {
2011        let created = Timestamp::now();
2012        let expires = created + Duration::from_hours(1);
2013        let metadata = Metadata {
2014            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
2015            time_created: Some(created),
2016            time_expires: Some(expires),
2017            ..Default::default()
2018        };
2019
2020        let gcs_object = GcsObject::from_metadata(&metadata);
2021        let custom_time = gcs_object.custom_time.map(Rfc3339Timestamp::into_inner);
2022        assert_eq!(custom_time, Some(expires));
2023
2024        let roundtripped = gcs_object.into_metadata().unwrap();
2025        assert_eq!(roundtripped.time_created, Some(created));
2026        assert_eq!(roundtripped.time_expires, Some(expires));
2027    }
2028
2029    #[tokio::test]
2030    async fn test_get_nonexistent() -> Result<()> {
2031        let backend = create_test_backend().await?;
2032
2033        let id = make_id();
2034        let result = backend.get_object(&id, Timestamp::now(), None).await?;
2035        assert!(result.is_none());
2036
2037        Ok(())
2038    }
2039
2040    #[tokio::test]
2041    async fn test_delete_nonexistent() -> Result<()> {
2042        let backend = create_test_backend().await?;
2043
2044        let id = make_id();
2045        backend.delete_object(&id, Timestamp::now()).await?;
2046
2047        Ok(())
2048    }
2049
2050    #[tokio::test]
2051    async fn test_overwrite() -> Result<()> {
2052        let backend = create_test_backend().await?;
2053
2054        let id = make_id();
2055        let metadata = Metadata {
2056            custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
2057            ..Default::default()
2058        };
2059
2060        backend
2061            .put_object(&id, &metadata, stream::single("hello"), Timestamp::now())
2062            .await?;
2063
2064        let metadata = Metadata {
2065            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
2066            ..Default::default()
2067        };
2068
2069        backend
2070            .put_object(&id, &metadata, stream::single("world"), Timestamp::now())
2071            .await?;
2072
2073        let (meta, _, stream) = backend
2074            .get_object(&id, Timestamp::now(), None)
2075            .await?
2076            .unwrap();
2077
2078        let payload = stream::read_to_vec(stream).await?;
2079        let str_payload = str::from_utf8(&payload).unwrap();
2080        assert_eq!(str_payload, "world");
2081        assert_eq!(meta.custom, metadata.custom);
2082
2083        Ok(())
2084    }
2085
2086    #[tokio::test]
2087    async fn test_read_after_delete() -> Result<()> {
2088        let backend = create_test_backend().await?;
2089
2090        let id = make_id();
2091        let metadata = Metadata::default();
2092
2093        backend
2094            .put_object(
2095                &id,
2096                &metadata,
2097                stream::single("hello, world"),
2098                Timestamp::now(),
2099            )
2100            .await?;
2101
2102        backend.delete_object(&id, Timestamp::now()).await?;
2103
2104        let result = backend.get_object(&id, Timestamp::now(), None).await?;
2105        assert!(result.is_none());
2106
2107        Ok(())
2108    }
2109
2110    #[tokio::test]
2111    async fn test_ttl_immediate() -> Result<()> {
2112        // NB: We create a TTL that immediately expires in this tests. This might be optimized away
2113        // in a future implementation, so we will have to update this test accordingly.
2114
2115        let backend = create_test_backend().await?;
2116
2117        let id = make_id();
2118        let metadata = Metadata {
2119            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2120            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2121            ..Default::default()
2122        };
2123
2124        backend
2125            .put_object(
2126                &id,
2127                &metadata,
2128                stream::single("hello, world"),
2129                Timestamp::now(),
2130            )
2131            .await?;
2132
2133        let result = backend.get_object(&id, Timestamp::now(), None).await?;
2134        assert!(result.is_none());
2135
2136        Ok(())
2137    }
2138
2139    #[tokio::test]
2140    async fn test_tti_immediate() -> Result<()> {
2141        // NB: We create a TTI that immediately expires in this tests. This might be optimized away
2142        // in a future implementation, so we will have to update this test accordingly.
2143
2144        let backend = create_test_backend().await?;
2145
2146        let id = make_id();
2147        let metadata = Metadata {
2148            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
2149            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2150            ..Default::default()
2151        };
2152
2153        backend
2154            .put_object(
2155                &id,
2156                &metadata,
2157                stream::single("hello, world"),
2158                Timestamp::now(),
2159            )
2160            .await?;
2161
2162        let result = backend.get_object(&id, Timestamp::now(), None).await?;
2163        assert!(result.is_none());
2164
2165        Ok(())
2166    }
2167
2168    #[tokio::test]
2169    async fn test_get_metadata_returns_metadata() -> Result<()> {
2170        let backend = create_test_backend().await?;
2171
2172        let id = make_id();
2173        let metadata = Metadata {
2174            content_type: "text/plain".into(),
2175            origin: Some("203.0.113.42".into()),
2176            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
2177            ..Default::default()
2178        };
2179
2180        backend
2181            .put_object(
2182                &id,
2183                &metadata,
2184                stream::single("hello, world"),
2185                Timestamp::now(),
2186            )
2187            .await?;
2188
2189        let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
2190        assert_eq!(meta.content_type, metadata.content_type);
2191        assert_eq!(meta.origin, metadata.origin);
2192        assert_eq!(meta.custom, metadata.custom);
2193
2194        Ok(())
2195    }
2196
2197    #[tokio::test]
2198    async fn test_get_metadata_nonexistent() -> Result<()> {
2199        let backend = create_test_backend().await?;
2200
2201        let id = make_id();
2202        let result = backend.get_metadata(&id, Timestamp::now()).await?;
2203        assert!(result.is_none());
2204
2205        Ok(())
2206    }
2207
2208    #[tokio::test]
2209    async fn test_set_expiry() -> Result<()> {
2210        for is_ttl in [false, true] {
2211            let backend = create_test_backend().await?;
2212
2213            let id = make_id();
2214            let tti = Duration::from_hours(2 * 24);
2215            let metadata = Metadata {
2216                content_type: "text/plain".into(),
2217                custom: [("preserved".into(), "yes".into())].into(),
2218                expiration_policy: if is_ttl {
2219                    ExpirationPolicy::TimeToLive(tti)
2220                } else {
2221                    ExpirationPolicy::TimeToIdle(tti)
2222                },
2223                time_expires: Some(Timestamp::now() + tti),
2224                ..Default::default()
2225            };
2226
2227            backend
2228                .put_object(
2229                    &id,
2230                    &metadata,
2231                    stream::single("hello, world"),
2232                    Timestamp::now(),
2233                )
2234                .await?;
2235
2236            // Backdate custom_time while keeping the object live.
2237            let object_url = backend.object_url(&id)?;
2238            let old_deadline = Timestamp::now() + Duration::from_mins(1);
2239            let generations = get_gcs_generations(&backend, object_url.clone()).await?;
2240            backend
2241                .update_custom_time(
2242                    object_url,
2243                    old_deadline,
2244                    (&generations.0, &generations.1),
2245                    None,
2246                )
2247                .await?;
2248
2249            // Backend reads return the stored deadline without modifying it.
2250            let pre_meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
2251            let pre_expiry = pre_meta.time_expires.unwrap();
2252            let created = pre_meta.time_created.unwrap();
2253            assert_eq!(
2254                backend
2255                    .get_metadata(&id, Timestamp::now())
2256                    .await?
2257                    .unwrap()
2258                    .time_expires,
2259                Some(pre_expiry)
2260            );
2261
2262            let requested = created + tti + tti;
2263            for target in [
2264                ExpiryTarget::At(requested),
2265                ExpiryTarget::FromCreation(tti + tti),
2266            ] {
2267                assert_eq!(
2268                    backend
2269                        .set_expiry(&id, target.into(), Timestamp::now())
2270                        .await?,
2271                    SetExpiryResponse::Satisfied(requested)
2272                );
2273            }
2274            assert_eq!(
2275                backend
2276                    .get_metadata(&id, Timestamp::now())
2277                    .await?
2278                    .unwrap()
2279                    .time_expires,
2280                Some(requested)
2281            );
2282
2283            let (updated, _, stream) = backend
2284                .get_object(&id, Timestamp::now(), None)
2285                .await?
2286                .unwrap();
2287            assert_eq!(
2288                updated.expiration_policy,
2289                if is_ttl {
2290                    ExpirationPolicy::TimeToLive(tti + tti)
2291                } else {
2292                    ExpirationPolicy::TimeToIdle(tti)
2293                }
2294            );
2295            assert_eq!(updated.custom, metadata.custom);
2296            let payload = stream::read_to_vec(stream).await?;
2297            assert_eq!(&payload, b"hello, world");
2298        }
2299        Ok(())
2300    }
2301
2302    #[tokio::test]
2303    async fn test_expiry_conflict() -> Result<()> {
2304        let backend = create_test_backend().await?;
2305        let id = make_id();
2306        let metadata = Metadata {
2307            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
2308            time_expires: Some(Timestamp::now() + Duration::from_mins(10)),
2309            ..Default::default()
2310        };
2311        backend
2312            .put_object(&id, &metadata, stream::single("payload"), Timestamp::now())
2313            .await?;
2314        let object_url = backend.object_url(&id)?;
2315        let generations = get_gcs_generations(&backend, object_url.clone()).await?;
2316
2317        let deadline = Timestamp::now() + Duration::from_hours(1);
2318        assert_eq!(
2319            backend
2320                .update_custom_time(
2321                    object_url.clone(),
2322                    deadline,
2323                    (&generations.0, &generations.1),
2324                    None,
2325                )
2326                .await?,
2327            SetExpiryResponse::Satisfied(deadline)
2328        );
2329        assert_eq!(
2330            backend
2331                .update_custom_time(
2332                    object_url.clone(),
2333                    Timestamp::now() + Duration::from_hours(2),
2334                    (&generations.0, &generations.1),
2335                    None,
2336                )
2337                .await?,
2338            SetExpiryResponse::Rejected
2339        );
2340
2341        backend.delete_object(&id, Timestamp::now()).await?;
2342        assert_eq!(
2343            backend
2344                .update_custom_time(
2345                    object_url,
2346                    Timestamp::now() + Duration::from_hours(2),
2347                    (&generations.0, &generations.1),
2348                    None,
2349                )
2350                .await?,
2351            SetExpiryResponse::NotFound
2352        );
2353        Ok(())
2354    }
2355
2356    #[tokio::test]
2357    async fn test_compressed_payload_roundtrip() -> Result<()> {
2358        use objectstore_types::metadata::Compression;
2359
2360        let backend = create_test_backend().await?;
2361
2362        let plaintext = b"hello, world (but compressed with zstd)";
2363        let compressed = zstd::encode_all(&plaintext[..], 3)?;
2364
2365        let id = make_id();
2366        let metadata = Metadata {
2367            content_type: "text/plain".into(),
2368            compression: Some(Compression::Zstd),
2369            ..Default::default()
2370        };
2371
2372        backend
2373            .put_object(
2374                &id,
2375                &metadata,
2376                stream::single(compressed.clone()),
2377                Timestamp::now(),
2378            )
2379            .await?;
2380
2381        let (meta, _, stream) = backend
2382            .get_object(&id, Timestamp::now(), None)
2383            .await?
2384            .unwrap();
2385        let payload = stream::read_to_vec(stream).await?;
2386
2387        assert_eq!(meta.compression, Some(Compression::Zstd));
2388        assert_eq!(
2389            payload, compressed,
2390            "Payload should be returned still compressed, not auto-decompressed"
2391        );
2392
2393        Ok(())
2394    }
2395
2396    fn gzip_encode(data: &[u8]) -> Vec<u8> {
2397        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2398        encoder.write_all(data).unwrap();
2399        encoder.finish().unwrap()
2400    }
2401
2402    /// GCS decompresses `Content-Encoding: gzip` objects on download unless the request accepts
2403    /// gzip. Full and range reads must return the stored compressed bytes.
2404    #[tokio::test]
2405    async fn test_gzip_payload_full_and_range_reads() -> Result<()> {
2406        use objectstore_types::metadata::Compression;
2407
2408        let backend = create_test_backend().await?;
2409
2410        let plaintext = b"hello, world (but compressed with gzip)";
2411        let compressed = gzip_encode(plaintext);
2412
2413        let id = make_id();
2414        let metadata = Metadata {
2415            content_type: "text/plain".into(),
2416            compression: Some(Compression::Gzip),
2417            ..Default::default()
2418        };
2419
2420        backend
2421            .put_object(
2422                &id,
2423                &metadata,
2424                stream::single(compressed.clone()),
2425                Timestamp::now(),
2426            )
2427            .await?;
2428
2429        let (meta, _, stream) = backend
2430            .get_object(&id, Timestamp::now(), None)
2431            .await?
2432            .unwrap();
2433        let payload = stream::read_to_vec(stream).await?;
2434
2435        assert_eq!(meta.compression, Some(Compression::Gzip));
2436        assert_eq!(
2437            payload, compressed,
2438            "Payload should be returned still compressed, not transcoded"
2439        );
2440
2441        let (meta, content_range, stream) = backend
2442            .get_object(&id, Timestamp::now(), Some(ByteRange::Bounded(2, 9)))
2443            .await?
2444            .unwrap();
2445        let payload = stream::read_to_vec(stream).await?;
2446
2447        assert_eq!(meta.compression, Some(Compression::Gzip));
2448        assert_eq!(
2449            content_range,
2450            Some(ContentRange {
2451                start: 2,
2452                end: 9,
2453                total: compressed.len() as u64,
2454            })
2455        );
2456        assert_eq!(payload, &compressed[2..=9]);
2457
2458        Ok(())
2459    }
2460    #[cfg(feature = "storage-cogs")]
2461    #[tokio::test]
2462    async fn change_stream_reports_the_size_gcs_stored() -> Result<()> {
2463        let (backend, producer) = create_test_backend_with_change_stream().await?;
2464        let id = make_id();
2465        let payload = vec![b'x'; 4096];
2466        let metadata = Metadata {
2467            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(3600)),
2468            time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2469            ..Default::default()
2470        };
2471
2472        backend
2473            .put_object(
2474                &id,
2475                &metadata,
2476                stream::single::<ClientError>(payload.clone()),
2477                Timestamp::now(),
2478            )
2479            .await?;
2480
2481        let records = producer.records();
2482        assert_eq!(records.len(), 1);
2483        assert_eq!(records[0].op_type, OpType::Write);
2484        assert_eq!(records[0].shared_resource_id, "gcs_objectstore");
2485        assert_eq!(records[0].app_feature, "testing");
2486        assert_eq!(
2487            records[0].size,
2488            Some(payload.len() as u64 + GcsObject::from_metadata(&metadata).metadata_size())
2489        );
2490        assert!(records[0].expiration_time.is_some());
2491
2492        Ok(())
2493    }
2494
2495    #[cfg(feature = "storage-cogs")]
2496    #[tokio::test]
2497    async fn resumable_completion_reports_to_change_stream() -> Result<()> {
2498        let (backend, producer) = create_test_backend_with_change_stream().await?;
2499        let id = make_id();
2500        let payload = b"resumable payload".to_vec();
2501        let metadata = Metadata {
2502            time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2503            ..Default::default()
2504        };
2505        let token = backend
2506            .create_upload_session(&id, &metadata, nonzero(payload.len() as u64))
2507            .await?;
2508
2509        assert_eq!(
2510            backend
2511                .put_chunk(
2512                    &token,
2513                    0,
2514                    payload.len() as u64,
2515                    stream::single::<ClientError>(payload.clone()),
2516                )
2517                .await?,
2518            UploadProgress::Complete
2519        );
2520
2521        let records = producer.records();
2522        assert_eq!(records.len(), 1);
2523        assert_eq!(records[0].op_type, OpType::Write);
2524        assert_eq!(
2525            records[0].size,
2526            Some(payload.len() as u64 + GcsObject::from_metadata(&metadata).metadata_size())
2527        );
2528        assert!(records[0].expiration_time.is_some());
2529        Ok(())
2530    }
2531
2532    #[cfg(feature = "storage-cogs")]
2533    #[tokio::test]
2534    async fn resumable_completion_after_corrupt_response_reports_to_change_stream() -> Result<()> {
2535        let (mut backend, producer) = create_test_backend_with_change_stream().await?;
2536        let id = make_id_with_key("resumable-change-stream-after-corrupt-response");
2537        let payload = b"final".to_vec();
2538        let metadata = Metadata::default();
2539        let token = backend
2540            .create_upload_session(&id, &metadata, nonzero(payload.len() as u64))
2541            .await?;
2542        inject_retry_test(
2543            &mut backend,
2544            "storage.objects.insert",
2545            "return-broken-stream-final-chunk-after-0B",
2546        )
2547        .await?;
2548
2549        assert!(matches!(
2550            backend
2551                .put_chunk(
2552                    &token,
2553                    0,
2554                    payload.len() as u64,
2555                    stream::single::<ClientError>(payload.clone()),
2556                )
2557                .await,
2558            Err(error) if error.kind() == ErrorKind::CorruptData
2559        ));
2560        assert_eq!(
2561            backend.upload_offset(&token).await?,
2562            UploadProgress::Complete
2563        );
2564
2565        let records = producer.records();
2566        assert_eq!(records.len(), 1);
2567        assert_eq!(records[0].op_type, OpType::Write);
2568        assert_eq!(
2569            records[0].size,
2570            Some(payload.len() as u64 + GcsObject::from_metadata(&metadata).metadata_size())
2571        );
2572        Ok(())
2573    }
2574
2575    #[cfg(feature = "storage-cogs")]
2576    #[tokio::test]
2577    async fn change_stream_size_includes_metadata_keys_and_values() -> Result<()> {
2578        let (backend, producer) = create_test_backend_with_change_stream().await?;
2579        let payload = b"tiny".to_vec();
2580
2581        let bare = Metadata::default();
2582        backend
2583            .put_object(
2584                &make_id(),
2585                &bare,
2586                stream::single::<ClientError>(payload.clone()),
2587                Timestamp::now(),
2588            )
2589            .await?;
2590
2591        let annotated = Metadata {
2592            custom: BTreeMap::from_iter([("a-fairly-long-metadata-key".into(), "value".into())]),
2593            ..Default::default()
2594        };
2595        backend
2596            .put_object(
2597                &make_id(),
2598                &annotated,
2599                stream::single::<ClientError>(payload.clone()),
2600                Timestamp::now(),
2601            )
2602            .await?;
2603
2604        let records = producer.records();
2605        assert_eq!(records.len(), 2);
2606
2607        let bare_size = records[0].size.unwrap();
2608        let annotated_size = records[1].size.unwrap();
2609        assert_eq!(
2610            bare_size,
2611            payload.len() as u64,
2612            "default metadata contributes no custom keys"
2613        );
2614        assert!(
2615            annotated_size > bare_size,
2616            "same payload, more metadata: {annotated_size} should exceed {bare_size}"
2617        );
2618
2619        Ok(())
2620    }
2621
2622    #[cfg(feature = "storage-cogs")]
2623    #[tokio::test]
2624    async fn change_stream_reports_nothing_when_the_object_was_already_gone() -> Result<()> {
2625        let (backend, producer) = create_test_backend_with_change_stream().await?;
2626
2627        backend.delete_object(&make_id(), Timestamp::now()).await?;
2628
2629        assert!(producer.records().is_empty());
2630
2631        Ok(())
2632    }
2633
2634    #[cfg(feature = "storage-cogs")]
2635    #[tokio::test]
2636    async fn change_stream_reports_deletes() -> Result<()> {
2637        let (backend, producer) = create_test_backend_with_change_stream().await?;
2638        let id = make_id();
2639
2640        backend
2641            .put_object(
2642                &id,
2643                &Metadata::default(),
2644                stream::single::<ClientError>(b"hi".to_vec()),
2645                Timestamp::now(),
2646            )
2647            .await?;
2648        producer.clear();
2649
2650        backend.delete_object(&id, Timestamp::now()).await?;
2651
2652        let records = producer.records();
2653        assert_eq!(records.len(), 1, "a retried delete must report only once");
2654        assert_eq!(records[0].op_type, OpType::Delete);
2655        assert_eq!(records[0].size, None);
2656
2657        Ok(())
2658    }
2659
2660    #[cfg(feature = "storage-cogs")]
2661    #[tokio::test]
2662    async fn change_stream_reports_expiry_extension_as_an_update() -> Result<()> {
2663        let (backend, producer) = create_test_backend_with_change_stream().await?;
2664        let id = make_id();
2665        let metadata = Metadata {
2666            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
2667            time_expires: Some(Timestamp::now() + Duration::from_secs(1)),
2668            ..Default::default()
2669        };
2670
2671        backend
2672            .put_object(
2673                &id,
2674                &metadata,
2675                stream::single::<ClientError>(b"hi".to_vec()),
2676                Timestamp::now(),
2677            )
2678            .await?;
2679        producer.clear();
2680
2681        backend.get_metadata(&id, Timestamp::now()).await?;
2682        assert!(producer.records().is_empty());
2683
2684        backend
2685            .set_expiry(
2686                &id,
2687                ExpiryTarget::At(Timestamp::now() + Duration::from_secs(3600)).into(),
2688                Timestamp::now(),
2689            )
2690            .await?;
2691
2692        let records = producer.records();
2693        assert_eq!(records.len(), 1);
2694        assert_eq!(records[0].op_type, OpType::Update);
2695        assert_eq!(records[0].size, None);
2696        assert!(records[0].expiration_time.is_some());
2697
2698        Ok(())
2699    }
2700}