Skip to main content

objectstore_service/backend/
s3_compatible.rs

1//! S3-compatible backend with generic protocol support.
2
3use std::convert::Infallible;
4use std::error::Error as StdError;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7use std::{fmt, io};
8
9use futures_util::{StreamExt, TryStreamExt};
10use objectstore_types::metadata::{HEADER_SIZE, Metadata};
11use objectstore_types::range::{ByteRange, ContentRange};
12use objectstore_types::time::Timestamp;
13use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
14use reqwest::{Body, IntoUrl, Method, RequestBuilder, Response, StatusCode};
15
16use super::extensions::{ResponseExt, SendTraced};
17use crate::backend::common::{
18    self, Backend, DeleteResponse, GetResponse, MetadataResponse, PutResponse,
19};
20use crate::backend::extensions::ReqwestResultExt;
21use crate::change_stream::{
22    ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
23};
24use crate::error::{Error, ErrorKind, Result, ResultExt as _};
25use crate::id::ObjectId;
26use crate::stream::{ClientStream, counting_stream};
27
28/// Configuration for [`S3CompatibleBackend`].
29///
30/// Supports [Amazon S3] and other S3-compatible services. Authentication is handled via
31/// environment variables (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) or IAM roles.
32///
33/// [Amazon S3]: https://aws.amazon.com/s3/
34///
35/// # Example
36///
37/// ```yaml
38/// storage:
39///   type: s3compatible
40///   endpoint: https://s3.amazonaws.com
41///   bucket: my-bucket
42/// ```
43#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
44pub struct S3CompatibleConfig {
45    /// S3 endpoint URL.
46    ///
47    /// Examples: `https://s3.amazonaws.com`, `http://localhost:9000` (for MinIO)
48    ///
49    /// # Environment Variables
50    ///
51    /// - `OS__STORAGE__TYPE=s3compatible`
52    /// - `OS__STORAGE__ENDPOINT=https://s3.amazonaws.com`
53    pub endpoint: String,
54
55    /// S3 bucket name.
56    ///
57    /// The bucket must exist before starting the server.
58    ///
59    /// # Environment Variables
60    ///
61    /// - `OS__STORAGE__BUCKET=my-bucket`
62    pub bucket: String,
63
64    /// Reports what this backend stores, for per-usecase cost attribution.
65    ///
66    /// # Default
67    ///
68    /// `None`, which disables reporting for this backend.
69    ///
70    /// # Environment Variables
71    ///
72    /// - `OS__STORAGE__COGS__SHARED_RESOURCE_ID=s3_objectstore`
73    /// - `OS__STORAGE__COGS__SAMPLE_RATE=1.0` (optional)
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub cogs: Option<CostTrackerStreamConfig>,
76}
77
78/// Prefix used for custom metadata in headers for the GCS backend.
79///
80/// See: <https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogmeta>
81const GCS_CUSTOM_PREFIX: &str = "x-goog-meta-";
82/// Header used to store the expiration time for GCS using the `daysSinceCustomTime` lifecycle
83/// condition.
84///
85/// See: <https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogcustomtime>
86const GCS_CUSTOM_TIME: &str = "x-goog-custom-time";
87
88/// An authentication token that can be passed as a bearer credential.
89pub trait Token: Send + Sync {
90    /// Returns the token string.
91    fn as_str(&self) -> &str;
92}
93
94/// Provides authentication tokens for S3-compatible requests.
95pub trait TokenProvider: Send + Sync + 'static {
96    /// Error returned when a token cannot be provided.
97    type Error: StdError + Send + Sync + 'static;
98
99    /// Returns a fresh token, fetching or refreshing it as needed.
100    fn get_token(
101        &self,
102    ) -> impl Future<Output = std::result::Result<impl Token, Self::Error>> + Send;
103}
104
105/// Placeholder [`TokenProvider`] for unauthenticated backends.
106#[derive(Debug)]
107pub struct NoToken;
108
109impl TokenProvider for NoToken {
110    type Error = Infallible;
111
112    #[allow(refining_impl_trait)]
113    async fn get_token(&self) -> std::result::Result<NoToken, Infallible> {
114        unimplemented!()
115    }
116}
117impl Token for NoToken {
118    fn as_str(&self) -> &str {
119        unimplemented!()
120    }
121}
122
123/// S3-compatible storage backend with pluggable authentication.
124pub struct S3CompatibleBackend<T> {
125    client: reqwest::Client,
126
127    endpoint: String,
128    bucket: String,
129
130    token_provider: Option<T>,
131
132    change_stream: Arc<dyn ChangeStream>,
133}
134
135impl<T> S3CompatibleBackend<T> {
136    /// Creates a new S3-compatible backend bound to the given bucket.
137    pub fn new(
138        config: S3CompatibleConfig,
139        token_provider: T,
140        streams: &ChangeStreamFactory,
141    ) -> Self {
142        Self::build(config, Some(token_provider), streams)
143    }
144
145    fn build(
146        config: S3CompatibleConfig,
147        token_provider: Option<T>,
148        streams: &ChangeStreamFactory,
149    ) -> Self {
150        let S3CompatibleConfig {
151            endpoint,
152            bucket,
153            cogs,
154        } = config;
155        Self {
156            client: common::reqwest_client(),
157            endpoint,
158            bucket,
159            token_provider,
160            change_stream: streams.build(cogs.as_ref()),
161        }
162    }
163
164    /// Formats the S3 object URL for the given key.
165    fn object_url(&self, id: &ObjectId) -> String {
166        format!("{}/{}/{}", self.endpoint, self.bucket, id.as_storage_path())
167    }
168}
169
170/// Number of bytes the given headers occupy as stored object metadata.
171fn headers_size(headers: &HeaderMap) -> u64 {
172    headers
173        .iter()
174        .map(|(name, value)| name.as_str().len() as u64 + value.len() as u64)
175        .sum()
176}
177
178/// Wraps [`Metadata::to_headers`] with GCS-specific concerns (tombstone + custom-time).
179fn metadata_to_gcs_headers(
180    metadata: &Metadata,
181    prefix: &str,
182) -> Result<HeaderMap, objectstore_types::metadata::Error> {
183    let mut headers = metadata.to_headers(prefix)?;
184
185    // The size is derived from the native `Content-Length` on every read, so it must not be
186    // persisted: metadata updates rewrite *all* stored metadata, and a stored `x-sn-size` key
187    // is rejected by the GCS JSON backend when it deserializes the object.
188    let size = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
189    headers.remove(&size);
190
191    // GCS custom-time for lifecycle expiration
192    if let Some(expires_at) = metadata.time_expires {
193        let expires_at = expires_at.as_rfc3339();
194        headers.append(GCS_CUSTOM_TIME, expires_at.to_string().parse()?);
195    }
196    Ok(headers)
197}
198
199impl<T> S3CompatibleBackend<T>
200where
201    T: TokenProvider,
202{
203    /// Creates a request builder with the appropriate authentication.
204    async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
205        let mut builder = self.client.request(method, url);
206        if let Some(provider) = &self.token_provider {
207            builder = builder.bearer_auth(
208                provider
209                    .get_token()
210                    .await
211                    .context(ErrorKind::BackendFailure, "getting S3 authentication token")?
212                    .as_str(),
213            );
214        }
215        Ok(builder)
216    }
217
218    /// Fetches object metadata using the given HTTP method (GET or HEAD) and
219    /// returns it with the response without modifying the object.
220    async fn request_object(
221        &self,
222        method: Method,
223        id: &ObjectId,
224        access_time: Timestamp,
225        range: Option<ByteRange>,
226    ) -> Result<Option<(Metadata, Option<ContentRange>, Response)>> {
227        let object_url = self.object_url(id);
228
229        let mut builder = self.request(method, &object_url).await?;
230        if let Some(r) = range {
231            builder = builder.header(reqwest::header::RANGE, r.to_header_value());
232        }
233        let response = builder
234            .send_traced()
235            .await
236            .reqwest_context("sending an S3 object request")?;
237
238        if response.status() == StatusCode::NOT_FOUND {
239            objectstore_log::debug!("Object not found");
240            response.drain_body().await;
241            return Ok(None);
242        }
243
244        if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
245            let raw = response
246                .headers()
247                .get(reqwest::header::CONTENT_RANGE)
248                .and_then(|v| v.to_str().ok());
249            let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
250            let err = match total {
251                Some(total) => ErrorKind::RangeNotSatisfiable { total }.into(),
252                None => Error::new(ErrorKind::BackendFailure, "invalid S3 416 Content-Range"),
253            };
254            response.drain_body().await;
255            return Err(err);
256        }
257
258        let response = response.check_error("getting an S3 object").await?;
259
260        let headers = response.headers();
261        let mut metadata = Metadata::from_headers(headers, GCS_CUSTOM_PREFIX)
262            .context(ErrorKind::CorruptData, "decoding S3 object metadata")?;
263
264        let content_range = if response.status() == StatusCode::PARTIAL_CONTENT {
265            let range = headers
266                .get(reqwest::header::CONTENT_RANGE)
267                .and_then(|v| v.to_str().ok())
268                .and_then(|s| s.parse::<ContentRange>().ok())
269                .ok_or_else(|| {
270                    Error::new(ErrorKind::BackendFailure, "missing S3 206 Content-Range")
271                })?;
272            metadata.size = Some(range.total as usize);
273            Some(range)
274        } else {
275            // NB: Read the header rather than `Response::content_length`, which reports the
276            // length of the decoded body and is therefore always zero for a HEAD response.
277            let size = headers
278                .get(reqwest::header::CONTENT_LENGTH)
279                .and_then(|value| value.to_str().ok())
280                .map(|value| value.parse::<usize>())
281                .transpose()
282                .context(ErrorKind::CorruptData, "decoding S3 Content-Length")?;
283
284            if let Some(size) = size {
285                metadata.size = Some(size);
286            } else {
287                objectstore_log::warn!("S3: 200 response missing Content-Length header");
288            }
289            None
290        };
291
292        // Filter already expired objects but leave them to garbage collection
293        if metadata.is_expired(access_time) {
294            objectstore_log::debug!("Object found but past expiry");
295            response.drain_body().await;
296            return Ok(None);
297        }
298
299        Ok(Some((metadata, content_range, response)))
300    }
301
302    /// Issues a request to update the metadata for the given object.
303    async fn update_metadata(
304        &self,
305        id: &ObjectId,
306        metadata: &Metadata,
307        etag: &HeaderValue,
308    ) -> Result<bool> {
309        // NB: Meta updates require CopyObject + REPLACE along with *all* metadata. See
310        // https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
311        let request = self
312            .request(Method::PUT, self.object_url(id))
313            .await?
314            .header(
315                "x-amz-copy-source",
316                format!("/{}/{}", self.bucket, id.as_storage_path()),
317            )
318            .header("x-amz-metadata-directive", "REPLACE")
319            .header("x-amz-copy-source-if-match", etag.clone())
320            .headers(
321                metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)
322                    .context(ErrorKind::InvalidMetadata, "encoding S3 object metadata")?,
323            );
324
325        let response = request.send_traced().await;
326        let response = response.reqwest_context("updating S3 expiration")?;
327        if matches!(
328            response.status(),
329            StatusCode::NOT_FOUND | StatusCode::CONFLICT | StatusCode::PRECONDITION_FAILED
330        ) {
331            response.drain_body().await;
332            return Ok(false);
333        }
334        response
335            .check_error("updating S3 expiration")
336            .await?
337            .drain_body()
338            .await;
339
340        Ok(true)
341    }
342}
343
344impl<T> fmt::Debug for S3CompatibleBackend<T> {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        f.debug_struct("S3Compatible")
347            .field("client", &self.client)
348            .field("endpoint", &self.endpoint)
349            .field("bucket", &self.bucket)
350            .finish_non_exhaustive()
351    }
352}
353
354impl S3CompatibleBackend<NoToken> {
355    /// Creates a new S3-compatible backend that sends unauthenticated requests.
356    pub fn without_token(config: S3CompatibleConfig, streams: &ChangeStreamFactory) -> Self {
357        Self::build(config, None, streams)
358    }
359}
360
361#[async_trait::async_trait]
362impl<T: TokenProvider> Backend for S3CompatibleBackend<T> {
363    fn name(&self) -> &'static str {
364        "s3-compatible"
365    }
366
367    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
368    async fn put_object(
369        &self,
370        id: &ObjectId,
371        metadata: &Metadata,
372        stream: ClientStream,
373        _access_time: Timestamp,
374    ) -> Result<PutResponse> {
375        objectstore_log::debug!("Writing to s3_compatible backend");
376        let headers = metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)
377            .context(ErrorKind::InvalidMetadata, "encoding S3 object metadata")?;
378        let metadata_size = headers_size(&headers);
379
380        // A successful PUT does not report the stored size back, so count what we send.
381        let (payload_size, counted) = counting_stream(stream);
382
383        self.request(Method::PUT, self.object_url(id))
384            .await?
385            .headers(headers)
386            .body(Body::wrap_stream(counted))
387            .send_traced()
388            .await
389            .check_error("uploading an S3 object")
390            .await?
391            .drain_body()
392            .await;
393
394        self.change_stream.write(
395            id,
396            metadata_size + payload_size.load(Ordering::Relaxed),
397            metadata.time_expires,
398        );
399
400        Ok(())
401    }
402
403    #[tracing::instrument(level = "debug", skip(self))]
404    async fn get_object(
405        &self,
406        id: &ObjectId,
407        access_time: Timestamp,
408        range: Option<ByteRange>,
409    ) -> Result<GetResponse> {
410        objectstore_log::debug!("Reading from s3_compatible backend");
411
412        let Some((metadata, content_range, response)) = self
413            .request_object(Method::GET, id, access_time, range)
414            .await?
415        else {
416            return Ok(None);
417        };
418
419        let stream = response.bytes_stream().map_err(io::Error::other);
420        Ok(Some((metadata, content_range, stream.boxed())))
421    }
422
423    #[tracing::instrument(level = "debug", skip(self))]
424    async fn get_metadata(
425        &self,
426        id: &ObjectId,
427        access_time: Timestamp,
428    ) -> Result<MetadataResponse> {
429        objectstore_log::debug!("Reading metadata from s3_compatible backend");
430        let response = self
431            .request_object(Method::HEAD, id, access_time, None)
432            .await?;
433        Ok(response.map(|(metadata, _, _)| metadata))
434    }
435
436    #[tracing::instrument(level = "debug", skip(self))]
437    async fn set_expiry(
438        &self,
439        id: &ObjectId,
440        expire_at: Timestamp,
441        access_time: Timestamp,
442    ) -> Result<bool> {
443        let Some((mut metadata, _, response)) = self
444            .request_object(Method::HEAD, id, access_time, None)
445            .await?
446        else {
447            return Ok(false);
448        };
449        let Some(current_expiry) = metadata.time_expires else {
450            response.drain_body().await;
451            return Ok(false);
452        };
453        if current_expiry >= expire_at {
454            response.drain_body().await;
455            return Ok(true); // already satisfied
456        }
457
458        let etag = response.headers().get(reqwest::header::ETAG).cloned();
459        response.drain_body().await;
460        let etag = etag.ok_or_else(|| {
461            Error::new(ErrorKind::BackendFailure, "S3 HEAD response missing ETag")
462        })?;
463
464        metadata.time_expires = Some(expire_at);
465        let applied = self.update_metadata(id, &metadata, &etag).await?;
466        if applied {
467            self.change_stream.update(id, Some(expire_at));
468        }
469
470        Ok(applied)
471    }
472
473    #[tracing::instrument(level = "debug", skip(self))]
474    async fn delete_object(
475        &self,
476        id: &ObjectId,
477        _access_time: Timestamp,
478    ) -> Result<DeleteResponse> {
479        objectstore_log::debug!("Deleting from s3_compatible backend");
480        let response = self
481            .request(Method::DELETE, self.object_url(id))
482            .await?
483            .send_traced()
484            .await
485            .reqwest_context("sending an S3 delete request")?;
486
487        // S3 deletes are idempotent; they return 204 whether a key existed or not. This
488        // branch catches other 404s, like from a missing bucket.
489        if response.status() == StatusCode::NOT_FOUND {
490            response.drain_body().await;
491            return Ok(());
492        }
493
494        response
495            .check_error("deleting an S3 object")
496            .await?
497            .drain_body()
498            .await;
499
500        // If the object didn't exist in the first place, this emits a spurious message
501        // due to S3 returning 204 to DELETEs whether the object existed or not.
502        self.change_stream.delete(id);
503
504        Ok(())
505    }
506
507    async fn join(&self) {
508        flush_change_stream(&self.change_stream).await;
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use std::collections::BTreeMap;
515    use std::io::{Read, Write};
516    use std::net::{TcpListener, TcpStream};
517    use std::sync::mpsc;
518    use std::thread;
519    use std::time::Duration;
520
521    use anyhow::Result;
522    use objectstore_types::metadata::ExpirationPolicy;
523    use objectstore_types::scope::{Scope, Scopes};
524
525    use super::*;
526    use crate::backend::common::Backend;
527    use crate::id::ObjectContext;
528    use crate::stream;
529
530    // NB: To run these tests, you need to have a MinIO server running. This is done
531    // automatically in CI.
532    //
533    // Refer to the readme for how to set up MinIO via devservices.
534
535    fn create_test_backend() -> S3CompatibleBackend<NoToken> {
536        S3CompatibleBackend::without_token(
537            S3CompatibleConfig {
538                endpoint: "http://localhost:8089".into(),
539                bucket: "test-bucket".into(),
540                cogs: None,
541            },
542            &ChangeStreamFactory::default(),
543        )
544    }
545
546    fn make_id() -> ObjectId {
547        ObjectId::random(ObjectContext {
548            usecase: "testing".into(),
549            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
550        })
551    }
552
553    fn read_http_request(connection: &mut TcpStream) -> String {
554        let mut bytes = Vec::new();
555        let mut byte = [0];
556        while !bytes.ends_with(b"\r\n\r\n") {
557            connection.read_exact(&mut byte).unwrap();
558            bytes.push(byte[0]);
559        }
560        String::from_utf8(bytes).unwrap()
561    }
562
563    fn start_copy_server(
564        copy_status: &'static str,
565    ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
566        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
567        let endpoint = format!("http://{}", listener.local_addr().unwrap());
568        let (request_tx, request_rx) = mpsc::channel();
569        let server = thread::spawn(move || {
570            let (mut copy, _) = listener.accept().unwrap();
571            request_tx.send(read_http_request(&mut copy)).unwrap();
572            write!(
573                copy,
574                "HTTP/1.1 {copy_status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
575            )
576            .unwrap();
577        });
578        (endpoint, request_rx, server)
579    }
580
581    #[tokio::test]
582    async fn update_metadata_uses_conditional_s3_copy() {
583        for (status, expected) in [
584            ("200 OK", true),
585            ("404 Not Found", false),
586            ("409 Conflict", false),
587            ("412 Precondition Failed", false),
588        ] {
589            let (endpoint, request_rx, server) = start_copy_server(status);
590            let backend = S3CompatibleBackend::without_token(
591                S3CompatibleConfig {
592                    endpoint,
593                    bucket: "bucket".into(),
594                    cogs: None,
595                },
596                &ChangeStreamFactory::default(),
597            );
598
599            assert_eq!(
600                backend
601                    .update_metadata(
602                        &make_id(),
603                        &Metadata::default(),
604                        &HeaderValue::from_static("\"etag\""),
605                    )
606                    .await
607                    .unwrap(),
608                expected
609            );
610            let request = request_rx.recv().unwrap().to_ascii_lowercase();
611            assert!(request.contains("x-amz-copy-source: /bucket/"));
612            assert!(request.contains("x-amz-metadata-directive: replace"));
613            assert!(request.contains("x-amz-copy-source-if-match: \"etag\""));
614            server.join().unwrap();
615        }
616    }
617
618    #[test]
619    fn metadata_to_gcs_headers_omits_size() {
620        let metadata = Metadata {
621            size: Some(4096),
622            ..Default::default()
623        };
624
625        let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
626
627        // Persisting the size would store a key that the GCS JSON backend rejects on read.
628        assert!(headers.get("x-goog-meta-x-sn-size").is_none());
629    }
630
631    #[test]
632    fn metadata_to_gcs_headers_uses_time_expires() {
633        let expires = Timestamp::now() + Duration::from_hours(1);
634        let metadata = Metadata {
635            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
636            time_expires: Some(expires),
637            ..Default::default()
638        };
639
640        let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
641        let custom_time = headers.get(GCS_CUSTOM_TIME).unwrap().to_str().unwrap();
642        let expected = expires.as_rfc3339().to_string();
643        assert_eq!(custom_time, expected);
644    }
645
646    #[test]
647    fn metadata_to_gcs_headers_escapes_unicode() {
648        let metadata = Metadata {
649            filename: Some("réport-📄.pdf".into()),
650            custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
651            ..Default::default()
652        };
653
654        let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
655        assert_eq!(
656            headers.get("x-goog-meta-x-sn-filename").unwrap(),
657            "r%C3%A9port-%F0%9F%93%84.pdf",
658        );
659        assert_eq!(
660            headers.get("x-goog-meta-x-snme-release").unwrap(),
661            "v%C3%A9rsion-1.0-%F0%9F%9A%80",
662        );
663
664        // The prefixed headers this backend writes are the ones it reads back.
665        let roundtripped = Metadata::from_headers(&headers, GCS_CUSTOM_PREFIX).unwrap();
666        assert_eq!(roundtripped.filename, metadata.filename);
667        assert_eq!(roundtripped.custom, metadata.custom);
668    }
669
670    #[test]
671    fn headers_size_counts_names_and_values() {
672        let mut headers = HeaderMap::new();
673        headers.insert("x-goog-meta-a", "1".parse().unwrap());
674        headers.insert("x-goog-meta-bb", "22".parse().unwrap());
675
676        assert_eq!(
677            headers_size(&headers),
678            ("x-goog-meta-a".len() + 1 + "x-goog-meta-bb".len() + 2) as u64
679        );
680    }
681
682    #[tokio::test]
683    async fn test_get_metadata_nonexistent() -> Result<()> {
684        let backend = create_test_backend();
685        let id = make_id();
686        let result = backend.get_metadata(&id, Timestamp::now()).await?;
687        assert!(result.is_none());
688        Ok(())
689    }
690
691    #[tokio::test]
692    #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
693    async fn test_get_metadata_reports_size() -> Result<()> {
694        let backend = create_test_backend();
695        let id = make_id();
696        let payload = "hello, world";
697
698        backend
699            .put_object(
700                &id,
701                &Metadata::default(),
702                stream::single(payload),
703                Timestamp::now(),
704            )
705            .await?;
706
707        // The size must come from the `Content-Length` header, not from the (empty) body of
708        // the HEAD response.
709        let metadata = backend
710            .get_metadata(&id, Timestamp::now())
711            .await?
712            .expect("object exists");
713        assert_eq!(metadata.size, Some(payload.len()));
714
715        Ok(())
716    }
717
718    #[tokio::test]
719    #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
720    async fn test_ttl_immediate() -> Result<()> {
721        let backend = create_test_backend();
722        let id = make_id();
723        let metadata = Metadata {
724            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
725            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
726            ..Default::default()
727        };
728
729        backend
730            .put_object(
731                &id,
732                &metadata,
733                stream::single("hello, world"),
734                Timestamp::now(),
735            )
736            .await?;
737
738        let get_result = backend.get_object(&id, Timestamp::now(), None).await?;
739        assert!(get_result.is_none());
740
741        let head_result = backend.get_metadata(&id, Timestamp::now()).await?;
742        assert!(head_result.is_none());
743
744        Ok(())
745    }
746
747    #[tokio::test]
748    #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
749    async fn test_tti_immediate() -> Result<()> {
750        let backend = create_test_backend();
751        let id = make_id();
752        let metadata = Metadata {
753            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
754            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
755            ..Default::default()
756        };
757
758        backend
759            .put_object(
760                &id,
761                &metadata,
762                stream::single("hello, world"),
763                Timestamp::now(),
764            )
765            .await?;
766
767        let get_result = backend.get_object(&id, Timestamp::now(), None).await?;
768        assert!(get_result.is_none());
769
770        let head_result = backend.get_metadata(&id, Timestamp::now()).await?;
771        assert!(head_result.is_none());
772
773        Ok(())
774    }
775}