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::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/// Configuration for [`S3CompatibleBackend`].
31///
32/// Supports [Amazon S3] and other S3-compatible services. Authentication is handled via
33/// environment variables (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) or IAM roles.
34///
35/// [Amazon S3]: https://aws.amazon.com/s3/
36///
37/// # Example
38///
39/// ```yaml
40/// storage:
41///   type: s3compatible
42///   endpoint: https://s3.amazonaws.com
43///   bucket: my-bucket
44/// ```
45#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
46pub struct S3CompatibleConfig {
47    /// S3 endpoint URL.
48    ///
49    /// Examples: `https://s3.amazonaws.com`, `http://localhost:9000` (for MinIO)
50    ///
51    /// # Environment Variables
52    ///
53    /// - `OS__STORAGE__TYPE=s3compatible`
54    /// - `OS__STORAGE__ENDPOINT=https://s3.amazonaws.com`
55    pub endpoint: String,
56
57    /// S3 bucket name.
58    ///
59    /// The bucket must exist before starting the server.
60    ///
61    /// # Environment Variables
62    ///
63    /// - `OS__STORAGE__BUCKET=my-bucket`
64    pub bucket: String,
65
66    /// Maximum time TCP data may remain unacknowledged or blocked before failing the request.
67    ///
68    /// This detects stalled transports. It does not limit the total upload duration or the time
69    /// waiting for a response after the upload is acknowledged. Applied on Linux and ignored on
70    /// other platforms. `0s` uses the operating system's default.
71    ///
72    /// # Default
73    ///
74    /// `30s`
75    ///
76    /// # Environment Variables
77    ///
78    /// - `OS__STORAGE__TCP_USER_TIMEOUT=30s`
79    #[serde(default = "common::default_tcp_user_timeout", with = "humantime_serde")]
80    pub tcp_user_timeout: Duration,
81
82    /// Reports what this backend stores, for per-usecase cost attribution.
83    ///
84    /// # Default
85    ///
86    /// `None`, which disables reporting for this backend.
87    ///
88    /// # Environment Variables
89    ///
90    /// - `OS__STORAGE__COGS__SHARED_RESOURCE_ID=s3_objectstore`
91    /// - `OS__STORAGE__COGS__SAMPLE_RATE=1.0` (optional)
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub cogs: Option<CostTrackerStreamConfig>,
94}
95
96/// Prefix used for custom metadata in headers for the GCS backend.
97///
98/// See: <https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogmeta>
99const GCS_CUSTOM_PREFIX: &str = "x-goog-meta-";
100/// Header used to store the expiration time for GCS using the `daysSinceCustomTime` lifecycle
101/// condition.
102///
103/// See: <https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogcustomtime>
104const GCS_CUSTOM_TIME: &str = "x-goog-custom-time";
105
106/// An authentication token that can be passed as a bearer credential.
107pub trait Token: Send + Sync {
108    /// Returns the token string.
109    fn as_str(&self) -> &str;
110}
111
112/// Provides authentication tokens for S3-compatible requests.
113pub trait TokenProvider: Send + Sync + 'static {
114    /// Error returned when a token cannot be provided.
115    type Error: StdError + Send + Sync + 'static;
116
117    /// Returns a fresh token, fetching or refreshing it as needed.
118    fn get_token(
119        &self,
120    ) -> impl Future<Output = std::result::Result<impl Token, Self::Error>> + Send;
121}
122
123/// Placeholder [`TokenProvider`] for unauthenticated backends.
124#[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
141/// S3-compatible storage backend with pluggable authentication.
142pub 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    /// Creates a new S3-compatible backend bound to the given bucket.
155    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    /// Formats the S3 object URL for the given key.
184    fn object_url(&self, id: &ObjectId) -> String {
185        format!("{}/{}/{}", self.endpoint, self.bucket, id.as_storage_path())
186    }
187}
188
189/// Number of bytes the given headers occupy as stored object metadata.
190fn 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
197/// Wraps [`Metadata::to_headers`] with GCS-specific concerns (tombstone + custom-time).
198fn 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    // The size is derived from the native `Content-Length` on every read, so it must not be
205    // persisted: metadata updates rewrite *all* stored metadata, and a stored `x-sn-size` key
206    // is rejected by the GCS JSON backend when it deserializes the object.
207    let size = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
208    headers.remove(&size);
209
210    // GCS custom-time for lifecycle expiration
211    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    /// Creates a request builder with the appropriate authentication.
223    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    /// Fetches object metadata using the given HTTP method (GET or HEAD) and
238    /// returns it with the response without modifying the object.
239    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            // NB: Read the header rather than `Response::content_length`, which reports the
295            // length of the decoded body and is therefore always zero for a HEAD response.
296            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        // Filter already expired objects but leave them to garbage collection
312        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    /// Issues a request to update the metadata for the given object.
322    async fn update_metadata(
323        &self,
324        id: &ObjectId,
325        metadata: &Metadata,
326        deadline: Timestamp,
327        etag: &HeaderValue,
328    ) -> Result<SetExpiryResponse> {
329        // NB: Meta updates require CopyObject + REPLACE along with *all* metadata. See
330        // https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
331        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    /// Creates a new S3-compatible backend that sends unauthenticated requests.
380    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        // A successful PUT does not report the stored size back, so count what we send.
408        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)); // already satisfied
490        }
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        // S3 deletes are idempotent; they return 204 whether a key existed or not. This
529        // branch catches other 404s, like from a missing bucket.
530        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        // If the object didn't exist in the first place, this emits a spurious message
542        // due to S3 returning 204 to DELETEs whether the object existed or not.
543        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    // NB: To run these tests, you need to have SeaweedFS running. This is done
572    // automatically in CI.
573    //
574    // Refer to the readme for how to set up SeaweedFS via devservices.
575
576    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        // Persisting the size would store a key that the GCS JSON backend rejects on read.
708        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        // The prefixed headers this backend writes are the ones it reads back.
745        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        // The size must come from the `Content-Length` header, not from the (empty) body of
787        // the HEAD response.
788        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}