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