1use std::convert::Infallible;
4use std::error::Error as StdError;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7use std::time::Duration;
8use std::{fmt, io};
9
10use futures_util::{StreamExt, TryStreamExt};
11use objectstore_types::metadata::{HEADER_SIZE, Metadata};
12use objectstore_types::range::{ByteRange, ContentRange};
13use objectstore_types::time::Timestamp;
14use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
15use reqwest::{Body, IntoUrl, Method, RequestBuilder, Response, StatusCode};
16
17use super::extensions::{ResponseExt, SendTraced};
18use crate::backend::common::{
19 self, Backend, DeleteResponse, ExpiryUpdate, GetResponse, MetadataResponse, PutResponse,
20 SetExpiryResponse,
21};
22use crate::backend::extensions::ReqwestResultExt;
23use crate::change_stream::{
24 ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
25};
26use crate::error::{Error, ErrorKind, Result, ResultExt as _};
27use crate::id::ObjectId;
28use crate::stream::{ClientStream, counting_stream};
29
30#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
46pub struct S3CompatibleConfig {
47 pub endpoint: String,
56
57 pub bucket: String,
65
66 #[serde(default = "common::default_tcp_user_timeout", with = "humantime_serde")]
80 pub tcp_user_timeout: Duration,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub cogs: Option<CostTrackerStreamConfig>,
94}
95
96const GCS_CUSTOM_PREFIX: &str = "x-goog-meta-";
100const GCS_CUSTOM_TIME: &str = "x-goog-custom-time";
105
106pub trait Token: Send + Sync {
108 fn as_str(&self) -> &str;
110}
111
112pub trait TokenProvider: Send + Sync + 'static {
114 type Error: StdError + Send + Sync + 'static;
116
117 fn get_token(
119 &self,
120 ) -> impl Future<Output = std::result::Result<impl Token, Self::Error>> + Send;
121}
122
123#[derive(Debug)]
125pub struct NoToken;
126
127impl TokenProvider for NoToken {
128 type Error = Infallible;
129
130 #[allow(refining_impl_trait)]
131 async fn get_token(&self) -> std::result::Result<NoToken, Infallible> {
132 unimplemented!()
133 }
134}
135impl Token for NoToken {
136 fn as_str(&self) -> &str {
137 unimplemented!()
138 }
139}
140
141pub struct S3CompatibleBackend<T> {
143 client: reqwest::Client,
144
145 endpoint: String,
146 bucket: String,
147
148 token_provider: Option<T>,
149
150 change_stream: Arc<dyn ChangeStream>,
151}
152
153impl<T> S3CompatibleBackend<T> {
154 pub fn new(
156 config: S3CompatibleConfig,
157 token_provider: T,
158 streams: &ChangeStreamFactory,
159 ) -> Self {
160 Self::build(config, Some(token_provider), streams)
161 }
162
163 fn build(
164 config: S3CompatibleConfig,
165 token_provider: Option<T>,
166 streams: &ChangeStreamFactory,
167 ) -> Self {
168 let S3CompatibleConfig {
169 endpoint,
170 bucket,
171 tcp_user_timeout,
172 cogs,
173 } = config;
174 Self {
175 client: common::reqwest_client(tcp_user_timeout),
176 endpoint,
177 bucket,
178 token_provider,
179 change_stream: streams.build(cogs.as_ref()),
180 }
181 }
182
183 fn object_url(&self, id: &ObjectId) -> String {
185 format!("{}/{}/{}", self.endpoint, self.bucket, id.as_storage_path())
186 }
187}
188
189fn headers_size(headers: &HeaderMap) -> u64 {
191 headers
192 .iter()
193 .map(|(name, value)| name.as_str().len() as u64 + value.len() as u64)
194 .sum()
195}
196
197fn metadata_to_gcs_headers(
199 metadata: &Metadata,
200 prefix: &str,
201) -> Result<HeaderMap, objectstore_types::metadata::Error> {
202 let mut headers = metadata.to_headers(prefix)?;
203
204 let size = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
208 headers.remove(&size);
209
210 if let Some(expires_at) = metadata.time_expires {
212 let expires_at = expires_at.as_rfc3339();
213 headers.append(GCS_CUSTOM_TIME, expires_at.to_string().parse()?);
214 }
215 Ok(headers)
216}
217
218impl<T> S3CompatibleBackend<T>
219where
220 T: TokenProvider,
221{
222 async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
224 let mut builder = self.client.request(method, url);
225 if let Some(provider) = &self.token_provider {
226 builder = builder.bearer_auth(
227 provider
228 .get_token()
229 .await
230 .context(ErrorKind::BackendFailure, "getting S3 authentication token")?
231 .as_str(),
232 );
233 }
234 Ok(builder)
235 }
236
237 async fn request_object(
240 &self,
241 method: Method,
242 id: &ObjectId,
243 access_time: Timestamp,
244 range: Option<ByteRange>,
245 ) -> Result<Option<(Metadata, Option<ContentRange>, Response)>> {
246 let object_url = self.object_url(id);
247
248 let mut builder = self.request(method, &object_url).await?;
249 if let Some(r) = range {
250 builder = builder.header(reqwest::header::RANGE, r.to_header_value());
251 }
252 let response = builder
253 .send_traced()
254 .await
255 .reqwest_context("sending an S3 object request")?;
256
257 if response.status() == StatusCode::NOT_FOUND {
258 objectstore_log::debug!("Object not found");
259 response.drain_body().await;
260 return Ok(None);
261 }
262
263 if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
264 let raw = response
265 .headers()
266 .get(reqwest::header::CONTENT_RANGE)
267 .and_then(|v| v.to_str().ok());
268 let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
269 let err = match total {
270 Some(total) => ErrorKind::RangeNotSatisfiable { total }.into(),
271 None => Error::new(ErrorKind::BackendFailure, "invalid S3 416 Content-Range"),
272 };
273 response.drain_body().await;
274 return Err(err);
275 }
276
277 let response = response.check_error("getting an S3 object").await?;
278
279 let headers = response.headers();
280 let mut metadata = Metadata::from_headers(headers, GCS_CUSTOM_PREFIX)
281 .context(ErrorKind::CorruptData, "decoding S3 object metadata")?;
282
283 let content_range = if response.status() == StatusCode::PARTIAL_CONTENT {
284 let range = headers
285 .get(reqwest::header::CONTENT_RANGE)
286 .and_then(|v| v.to_str().ok())
287 .and_then(|s| s.parse::<ContentRange>().ok())
288 .ok_or_else(|| {
289 Error::new(ErrorKind::BackendFailure, "missing S3 206 Content-Range")
290 })?;
291 metadata.size = Some(range.total as usize);
292 Some(range)
293 } else {
294 let size = headers
297 .get(reqwest::header::CONTENT_LENGTH)
298 .and_then(|value| value.to_str().ok())
299 .map(|value| value.parse::<usize>())
300 .transpose()
301 .context(ErrorKind::CorruptData, "decoding S3 Content-Length")?;
302
303 if let Some(size) = size {
304 metadata.size = Some(size);
305 } else {
306 objectstore_log::warn!("S3: 200 response missing Content-Length header");
307 }
308 None
309 };
310
311 if metadata.is_expired(access_time) {
313 objectstore_log::debug!("Object found but past expiry");
314 response.drain_body().await;
315 return Ok(None);
316 }
317
318 Ok(Some((metadata, content_range, response)))
319 }
320
321 async fn update_metadata(
323 &self,
324 id: &ObjectId,
325 metadata: &Metadata,
326 deadline: Timestamp,
327 etag: &HeaderValue,
328 ) -> Result<SetExpiryResponse> {
329 let request = self
332 .request(Method::PUT, self.object_url(id))
333 .await?
334 .header(
335 "x-amz-copy-source",
336 format!("/{}/{}", self.bucket, id.as_storage_path()),
337 )
338 .header("x-amz-metadata-directive", "REPLACE")
339 .header("x-amz-copy-source-if-match", etag.clone())
340 .headers(
341 metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)
342 .context(ErrorKind::InvalidMetadata, "encoding S3 object metadata")?,
343 );
344
345 let response = request.send_traced().await;
346 let response = response.reqwest_context("updating S3 expiration")?;
347 let outcome = match response.status() {
348 StatusCode::NOT_FOUND => Some(SetExpiryResponse::NotFound),
349 StatusCode::CONFLICT | StatusCode::PRECONDITION_FAILED => {
350 Some(SetExpiryResponse::Rejected)
351 }
352 _ => None,
353 };
354 if let Some(outcome) = outcome {
355 response.drain_body().await;
356 return Ok(outcome);
357 }
358 response
359 .check_error("updating S3 expiration")
360 .await?
361 .drain_body()
362 .await;
363
364 Ok(SetExpiryResponse::Satisfied(deadline))
365 }
366}
367
368impl<T> fmt::Debug for S3CompatibleBackend<T> {
369 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370 f.debug_struct("S3Compatible")
371 .field("client", &self.client)
372 .field("endpoint", &self.endpoint)
373 .field("bucket", &self.bucket)
374 .finish_non_exhaustive()
375 }
376}
377
378impl S3CompatibleBackend<NoToken> {
379 pub fn without_token(config: S3CompatibleConfig, streams: &ChangeStreamFactory) -> Self {
381 Self::build(config, None, streams)
382 }
383}
384
385#[async_trait::async_trait]
386impl<T: TokenProvider> Backend for S3CompatibleBackend<T> {
387 fn name(&self) -> &'static str {
388 "s3-compatible"
389 }
390
391 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
392 async fn put_object(
393 &self,
394 id: &ObjectId,
395 metadata: &Metadata,
396 stream: ClientStream,
397 _access_time: Timestamp,
398 ) -> Result<PutResponse> {
399 objectstore_log::debug!("Writing to s3_compatible backend");
400 self.change_stream
401 .begin_write(id, metadata.time_expires)
402 .await?;
403 let headers = metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)
404 .context(ErrorKind::InvalidMetadata, "encoding S3 object metadata")?;
405 let metadata_size = headers_size(&headers);
406
407 let (payload_size, counted) = counting_stream(stream);
409
410 self.request(Method::PUT, self.object_url(id))
411 .await?
412 .headers(headers)
413 .body(Body::wrap_stream(counted))
414 .send_traced()
415 .await
416 .check_error("uploading an S3 object")
417 .await?
418 .drain_body()
419 .await;
420
421 self.change_stream
422 .commit_write(
423 id,
424 metadata_size + payload_size.load(Ordering::Relaxed),
425 metadata.time_expires,
426 )
427 .await;
428
429 Ok(())
430 }
431
432 #[tracing::instrument(level = "debug", skip(self))]
433 async fn get_object(
434 &self,
435 id: &ObjectId,
436 access_time: Timestamp,
437 range: Option<ByteRange>,
438 ) -> Result<GetResponse> {
439 objectstore_log::debug!("Reading from s3_compatible backend");
440
441 let Some((metadata, content_range, response)) = self
442 .request_object(Method::GET, id, access_time, range)
443 .await?
444 else {
445 return Ok(None);
446 };
447
448 let stream = response.bytes_stream().map_err(io::Error::other);
449 Ok(Some((metadata, content_range, stream.boxed())))
450 }
451
452 #[tracing::instrument(level = "debug", skip(self))]
453 async fn get_metadata(
454 &self,
455 id: &ObjectId,
456 access_time: Timestamp,
457 ) -> Result<MetadataResponse> {
458 objectstore_log::debug!("Reading metadata from s3_compatible backend");
459 let response = self
460 .request_object(Method::HEAD, id, access_time, None)
461 .await?;
462 Ok(response.map(|(metadata, _, _)| metadata))
463 }
464
465 #[tracing::instrument(level = "debug", skip(self))]
466 async fn set_expiry(
467 &self,
468 id: &ObjectId,
469 target: ExpiryUpdate,
470 access_time: Timestamp,
471 ) -> Result<SetExpiryResponse> {
472 let Some((mut metadata, _, response)) = self
473 .request_object(Method::HEAD, id, access_time, None)
474 .await?
475 else {
476 return Ok(SetExpiryResponse::NotFound);
477 };
478
479 let etag = response.headers().get(reqwest::header::ETAG).cloned();
480 response.drain_body().await;
481
482 let Some(current_expiry) = metadata.time_expires else {
483 return Ok(SetExpiryResponse::Rejected);
484 };
485 let Some(expire_at) = target.resolve(metadata.time_created, access_time)? else {
486 return Ok(SetExpiryResponse::Rejected);
487 };
488 if current_expiry >= expire_at {
489 return Ok(SetExpiryResponse::Satisfied(expire_at)); }
491
492 let etag = etag.ok_or_else(|| {
493 Error::new(ErrorKind::BackendFailure, "S3 HEAD response missing ETag")
494 })?;
495
496 metadata.expiration_policy = common::extended_expiration_policy(
497 metadata.expiration_policy,
498 metadata.time_created,
499 current_expiry,
500 expire_at,
501 )?;
502 metadata.time_expires = Some(expire_at);
503 self.change_stream.begin_update(id, Some(expire_at)).await?;
504 let outcome = self
505 .update_metadata(id, &metadata, expire_at, &etag)
506 .await?;
507 if matches!(outcome, SetExpiryResponse::Satisfied(_)) {
508 self.change_stream.commit_update(id, Some(expire_at)).await;
509 }
510
511 Ok(outcome)
512 }
513
514 #[tracing::instrument(level = "debug", skip(self))]
515 async fn delete_object(
516 &self,
517 id: &ObjectId,
518 _access_time: Timestamp,
519 ) -> Result<DeleteResponse> {
520 objectstore_log::debug!("Deleting from s3_compatible backend");
521 let response = self
522 .request(Method::DELETE, self.object_url(id))
523 .await?
524 .send_traced()
525 .await
526 .reqwest_context("sending an S3 delete request")?;
527
528 if response.status() == StatusCode::NOT_FOUND {
531 response.drain_body().await;
532 return Ok(());
533 }
534
535 response
536 .check_error("deleting an S3 object")
537 .await?
538 .drain_body()
539 .await;
540
541 self.change_stream.commit_delete(id).await;
544
545 Ok(())
546 }
547
548 async fn join(&self) {
549 flush_change_stream(&self.change_stream).await;
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use std::collections::BTreeMap;
556 use std::io::{Read, Write};
557 use std::net::{TcpListener, TcpStream};
558 use std::sync::mpsc;
559 use std::thread;
560 use std::time::Duration;
561
562 use anyhow::Result;
563 use objectstore_types::metadata::ExpirationPolicy;
564 use objectstore_types::scope::{Scope, Scopes};
565
566 use super::*;
567 use crate::backend::common::Backend;
568 use crate::id::ObjectContext;
569 use crate::stream;
570
571 fn create_test_backend() -> S3CompatibleBackend<NoToken> {
577 S3CompatibleBackend::without_token(
578 S3CompatibleConfig {
579 endpoint: "http://localhost:8089".into(),
580 bucket: "test-bucket".into(),
581 tcp_user_timeout: Duration::from_secs(30),
582 cogs: None,
583 },
584 &ChangeStreamFactory::default(),
585 )
586 }
587
588 fn make_id() -> ObjectId {
589 ObjectId::random(ObjectContext {
590 usecase: "testing".parse().unwrap(),
591 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
592 })
593 }
594
595 fn read_http_request(connection: &mut TcpStream) -> String {
596 let mut bytes = Vec::new();
597 let mut byte = [0];
598 while !bytes.ends_with(b"\r\n\r\n") {
599 connection.read_exact(&mut byte).unwrap();
600 bytes.push(byte[0]);
601 }
602 String::from_utf8(bytes).unwrap()
603 }
604
605 fn start_copy_server(
606 copy_status: &'static str,
607 metadata: Metadata,
608 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
609 let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
610 let endpoint = format!("http://{}", listener.local_addr().unwrap());
611 let (request_tx, request_rx) = mpsc::channel();
612 let server = thread::spawn(move || {
613 let (mut head, _) = listener.accept().unwrap();
614 assert!(read_http_request(&mut head).starts_with("HEAD "));
615 write!(
616 head,
617 "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nETag: \"etag\"\r\nConnection: close\r\n"
618 )
619 .unwrap();
620 for (name, value) in metadata.to_headers(GCS_CUSTOM_PREFIX).unwrap().iter() {
621 write!(head, "{name}: {}\r\n", value.to_str().unwrap()).unwrap();
622 }
623 write!(head, "\r\n").unwrap();
624 let (mut copy, _) = listener.accept().unwrap();
625 request_tx.send(read_http_request(&mut copy)).unwrap();
626 write!(
627 copy,
628 "HTTP/1.1 {copy_status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
629 )
630 .unwrap();
631 });
632 (endpoint, request_rx, server)
633 }
634
635 #[tokio::test]
636 async fn update_metadata_uses_conditional_s3_copy() {
637 let created = Timestamp::now();
638 let deadline = created + Duration::from_hours(2);
639 for (status, expected) in [
640 ("200 OK", SetExpiryResponse::Satisfied(deadline)),
641 ("404 Not Found", SetExpiryResponse::NotFound),
642 ("409 Conflict", SetExpiryResponse::Rejected),
643 ("412 Precondition Failed", SetExpiryResponse::Rejected),
644 ] {
645 let (endpoint, request_rx, server) = start_copy_server(
646 status,
647 Metadata {
648 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
649 time_created: Some(created),
650 time_expires: Some(created + Duration::from_hours(1)),
651 custom: [("preserved".into(), "yes".into())].into(),
652 ..Default::default()
653 },
654 );
655 let backend = S3CompatibleBackend::without_token(
656 S3CompatibleConfig {
657 endpoint,
658 bucket: "bucket".into(),
659 tcp_user_timeout: Duration::from_secs(30),
660 cogs: None,
661 },
662 &ChangeStreamFactory::default(),
663 );
664
665 assert_eq!(
666 backend
667 .set_expiry(
668 &make_id(),
669 common::ExpiryTarget::At(deadline).into(),
670 created
671 )
672 .await
673 .unwrap(),
674 expected
675 );
676 let request = request_rx.recv().unwrap().to_ascii_lowercase();
677 assert!(request.contains("x-amz-copy-source: /bucket/"));
678 assert!(request.contains("x-amz-metadata-directive: replace"));
679 assert!(request.contains("x-amz-copy-source-if-match: \"etag\""));
680 let expected_headers = Metadata {
681 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(2)),
682 time_created: Some(created),
683 time_expires: Some(deadline),
684 custom: [("preserved".into(), "yes".into())].into(),
685 ..Default::default()
686 }
687 .to_headers(GCS_CUSTOM_PREFIX)
688 .unwrap();
689 for (name, value) in &expected_headers {
690 assert!(request.contains(
691 &format!("{name}: {}", value.to_str().unwrap()).to_ascii_lowercase()
692 ));
693 }
694 server.join().unwrap();
695 }
696 }
697
698 #[test]
699 fn metadata_to_gcs_headers_omits_size() {
700 let metadata = Metadata {
701 size: Some(4096),
702 ..Default::default()
703 };
704
705 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
706
707 assert!(headers.get("x-goog-meta-x-sn-size").is_none());
709 }
710
711 #[test]
712 fn metadata_to_gcs_headers_uses_time_expires() {
713 let expires = Timestamp::now() + Duration::from_hours(1);
714 let metadata = Metadata {
715 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
716 time_expires: Some(expires),
717 ..Default::default()
718 };
719
720 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
721 let custom_time = headers.get(GCS_CUSTOM_TIME).unwrap().to_str().unwrap();
722 let expected = expires.as_rfc3339().to_string();
723 assert_eq!(custom_time, expected);
724 }
725
726 #[test]
727 fn metadata_to_gcs_headers_escapes_unicode() {
728 let metadata = Metadata {
729 filename: Some("réport-📄.pdf".into()),
730 custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
731 ..Default::default()
732 };
733
734 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
735 assert_eq!(
736 headers.get("x-goog-meta-x-sn-filename").unwrap(),
737 "r%C3%A9port-%F0%9F%93%84.pdf",
738 );
739 assert_eq!(
740 headers.get("x-goog-meta-x-snme-release").unwrap(),
741 "v%C3%A9rsion-1.0-%F0%9F%9A%80",
742 );
743
744 let roundtripped = Metadata::from_headers(&headers, GCS_CUSTOM_PREFIX).unwrap();
746 assert_eq!(roundtripped.filename, metadata.filename);
747 assert_eq!(roundtripped.custom, metadata.custom);
748 }
749
750 #[test]
751 fn headers_size_counts_names_and_values() {
752 let mut headers = HeaderMap::new();
753 headers.insert("x-goog-meta-a", "1".parse().unwrap());
754 headers.insert("x-goog-meta-bb", "22".parse().unwrap());
755
756 assert_eq!(
757 headers_size(&headers),
758 ("x-goog-meta-a".len() + 1 + "x-goog-meta-bb".len() + 2) as u64
759 );
760 }
761
762 #[tokio::test]
763 async fn test_get_metadata_nonexistent() -> Result<()> {
764 let backend = create_test_backend();
765 let id = make_id();
766 let result = backend.get_metadata(&id, Timestamp::now()).await?;
767 assert!(result.is_none());
768 Ok(())
769 }
770
771 #[tokio::test]
772 async fn test_get_metadata_reports_size() -> Result<()> {
773 let backend = create_test_backend();
774 let id = make_id();
775 let payload = "hello, world";
776
777 backend
778 .put_object(
779 &id,
780 &Metadata::default(),
781 stream::single(payload),
782 Timestamp::now(),
783 )
784 .await?;
785
786 let metadata = backend
789 .get_metadata(&id, Timestamp::now())
790 .await?
791 .expect("object exists");
792 assert_eq!(metadata.size, Some(payload.len()));
793
794 Ok(())
795 }
796
797 #[tokio::test]
798 #[ignore = "SeaweedFS does not enforce expiration from GCS custom-time metadata"]
799 async fn test_ttl_immediate() -> Result<()> {
800 let backend = create_test_backend();
801 let id = make_id();
802 let metadata = Metadata {
803 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
804 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
805 ..Default::default()
806 };
807
808 backend
809 .put_object(
810 &id,
811 &metadata,
812 stream::single("hello, world"),
813 Timestamp::now(),
814 )
815 .await?;
816
817 let get_result = backend.get_object(&id, Timestamp::now(), None).await?;
818 assert!(get_result.is_none());
819
820 let head_result = backend.get_metadata(&id, Timestamp::now()).await?;
821 assert!(head_result.is_none());
822
823 Ok(())
824 }
825
826 #[tokio::test]
827 #[ignore = "SeaweedFS does not enforce expiration from GCS custom-time metadata"]
828 async fn test_tti_immediate() -> Result<()> {
829 let backend = create_test_backend();
830 let id = make_id();
831 let metadata = Metadata {
832 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
833 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
834 ..Default::default()
835 };
836
837 backend
838 .put_object(
839 &id,
840 &metadata,
841 stream::single("hello, world"),
842 Timestamp::now(),
843 )
844 .await?;
845
846 let get_result = backend.get_object(&id, Timestamp::now(), None).await?;
847 assert!(get_result.is_none());
848
849 let head_result = backend.get_metadata(&id, Timestamp::now()).await?;
850 assert!(head_result.is_none());
851
852 Ok(())
853 }
854}