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