Skip to main content

objectstore_types/
metadata.rs

1//! Per-object metadata types and HTTP header serialization.
2//!
3//! This module defines [`Metadata`], the per-object metadata structure that
4//! accompanies every stored object, along with [`ExpirationPolicy`] and
5//! [`Compression`].
6//!
7//! # Serialization
8//!
9//! Metadata has two serialization formats:
10//!
11//! - **HTTP headers** — used by the public API. [`Metadata::from_headers`] and
12//!   [`Metadata::to_headers`] handle this conversion for public fields only.
13//!   Internal fields like [`Metadata::is_redirect_tombstone`] are handled by
14//!   backends directly.
15//! - **JSON** — used internally by backends for storage. JSON serialization
16//!   includes additional internal fields (e.g. `is_redirect_tombstone`) that
17//!   are skipped in the header representation.
18//!
19//! # HTTP header prefixes
20//!
21//! Headers use three prefix conventions:
22//!
23//! - Standard HTTP headers where applicable (`Content-Type`, `Content-Encoding`)
24//! - `x-sn-*` for objectstore-specific fields (e.g. `x-sn-expiration`)
25//! - `x-snme-` for custom user metadata (e.g. `x-snme-build_id`)
26//!
27//! Backends that store metadata as object metadata (like GCS) layer their own
28//! prefix on top, so `x-sn-expiration` becomes `x-goog-meta-x-sn-expiration`.
29//! The [`Metadata::from_headers`] and [`Metadata::to_headers`] methods accept
30//! a `prefix` parameter for this purpose.
31
32use std::borrow::Cow;
33use std::collections::BTreeMap;
34use std::fmt;
35use std::str::FromStr;
36use std::time::{Duration, SystemTime};
37
38use http::header::{self, HeaderMap, HeaderName};
39use humantime::{format_duration, format_rfc3339_micros, parse_duration, parse_rfc3339};
40use serde::{Deserialize, Serialize};
41
42/// The custom HTTP header that contains the serialized [`ExpirationPolicy`].
43pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
44/// The custom HTTP header that contains the serialized redirect tombstone.
45pub const HEADER_REDIRECT_TOMBSTONE: &str = "x-sn-redirect-tombstone";
46/// The custom HTTP header that contains the object creation time.
47pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
48/// The custom HTTP header that contains the object expiration time.
49pub const HEADER_TIME_EXPIRES: &str = "x-sn-time-expires";
50/// The custom HTTP header that contains the origin of the object.
51pub const HEADER_ORIGIN: &str = "x-sn-origin";
52/// The prefix for custom HTTP headers containing custom per-object metadata.
53pub const HEADER_META_PREFIX: &str = "x-snme-";
54
55/// The default content type for objects without a known content type.
56pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
57
58/// Errors that can happen dealing with metadata
59#[derive(Debug, thiserror::Error)]
60pub enum Error {
61    /// Any problems dealing with http headers, essentially converting to/from [`str`].
62    #[error("error dealing with http headers")]
63    Header(#[from] Option<http::Error>),
64    /// The value for the expiration policy is invalid.
65    #[error("invalid expiration policy value")]
66    Expiration(#[from] Option<humantime::DurationError>),
67    /// The compression algorithm is invalid.
68    #[error("invalid compression value")]
69    Compression,
70    /// The content type is invalid.
71    #[error("invalid content type")]
72    ContentType(#[from] mediatype::MediaTypeError),
73    /// The creation time is invalid.
74    #[error("invalid creation time")]
75    CreationTime(#[from] humantime::TimestampError),
76}
77impl From<http::header::InvalidHeaderValue> for Error {
78    fn from(err: http::header::InvalidHeaderValue) -> Self {
79        Self::Header(Some(err.into()))
80    }
81}
82impl From<http::header::InvalidHeaderName> for Error {
83    fn from(err: http::header::InvalidHeaderName) -> Self {
84        Self::Header(Some(err.into()))
85    }
86}
87impl From<http::header::ToStrError> for Error {
88    fn from(_err: http::header::ToStrError) -> Self {
89        // the error happens when converting a header value back to a `str`
90        Self::Header(None)
91    }
92}
93
94/// The per-object expiration policy.
95///
96/// Controls automatic object cleanup. The policy is set by the client at upload
97/// time via the [`x-sn-expiration`](HEADER_EXPIRATION) header and persisted with
98/// the object.
99///
100/// | Variant      | Wire format | Behavior                                     |
101/// |--------------|-------------|----------------------------------------------|
102/// | `Manual`     | `manual`    | No automatic expiration (default)            |
103/// | `TimeToLive` | `ttl:30s`   | Expires after a fixed duration from creation |
104/// | `TimeToIdle` | `tti:1h`    | Expires after a duration of no access        |
105///
106/// Durations use [humantime](https://docs.rs/humantime) format (e.g. `30s`,
107/// `5m`, `1h`, `7d`).
108///
109/// **Important:** `Manual` is the default and must remain so — persisted objects
110/// without an explicit policy are deserialized as `Manual`.
111#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
112pub enum ExpirationPolicy {
113    /// Manual expiration, meaning no automatic cleanup.
114    // IMPORTANT: Do not change the default, we rely on this for persisted objects.
115    #[default]
116    Manual,
117    /// Time to live, with expiration after the specified duration.
118    TimeToLive(Duration),
119    /// Time to idle, with expiration once the object has not been accessed within the specified duration.
120    TimeToIdle(Duration),
121}
122impl ExpirationPolicy {
123    /// Returns the duration after which the object expires.
124    pub fn expires_in(&self) -> Option<Duration> {
125        match self {
126            ExpirationPolicy::Manual => None,
127            ExpirationPolicy::TimeToLive(duration) => Some(*duration),
128            ExpirationPolicy::TimeToIdle(duration) => Some(*duration),
129        }
130    }
131
132    /// Returns `true` if this policy indicates time-based expiry.
133    pub fn is_timeout(&self) -> bool {
134        match self {
135            ExpirationPolicy::TimeToLive(_) => true,
136            ExpirationPolicy::TimeToIdle(_) => true,
137            ExpirationPolicy::Manual => false,
138        }
139    }
140
141    /// Returns `true` if this policy is `Manual`.
142    pub fn is_manual(&self) -> bool {
143        *self == ExpirationPolicy::Manual
144    }
145}
146impl fmt::Display for ExpirationPolicy {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            ExpirationPolicy::TimeToLive(duration) => {
150                write!(f, "ttl:{}", format_duration(*duration))
151            }
152            ExpirationPolicy::TimeToIdle(duration) => {
153                write!(f, "tti:{}", format_duration(*duration))
154            }
155            ExpirationPolicy::Manual => f.write_str("manual"),
156        }
157    }
158}
159impl FromStr for ExpirationPolicy {
160    type Err = Error;
161
162    fn from_str(s: &str) -> Result<Self, Self::Err> {
163        if s == "manual" {
164            return Ok(ExpirationPolicy::Manual);
165        }
166        if let Some(duration) = s.strip_prefix("ttl:") {
167            return Ok(ExpirationPolicy::TimeToLive(parse_duration(duration)?));
168        }
169        if let Some(duration) = s.strip_prefix("tti:") {
170            return Ok(ExpirationPolicy::TimeToIdle(parse_duration(duration)?));
171        }
172        Err(Error::Expiration(None))
173    }
174}
175
176/// The compression algorithm applied to an object's payload.
177///
178/// Transmitted via the standard `Content-Encoding` HTTP header. Currently only
179/// Zstandard (`zstd`) is supported.
180#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
181pub enum Compression {
182    /// Compressed using `zstd`.
183    Zstd,
184    // /// Compressed using `gzip`.
185    // Gzip,
186    // /// Compressed using `lz4`.
187    // Lz4,
188}
189
190impl Compression {
191    /// Returns a string representation of the compression algorithm.
192    pub fn as_str(&self) -> &str {
193        match self {
194            Compression::Zstd => "zstd",
195            // Compression::Gzip => "gzip",
196            // Compression::Lz4 => "lz4",
197        }
198    }
199}
200
201impl fmt::Display for Compression {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.write_str(self.as_str())
204    }
205}
206
207impl FromStr for Compression {
208    type Err = Error;
209
210    fn from_str(s: &str) -> Result<Self, Self::Err> {
211        match s {
212            "zstd" => Ok(Compression::Zstd),
213            // "gzip" => Compression::Gzip,
214            // "lz4" => Compression::Lz4,
215            _ => Err(Error::Compression),
216        }
217    }
218}
219
220/// Per-object metadata.
221///
222/// Includes first-class fields (expiration, compression, timestamps, etc.) and
223/// arbitrary user-provided key-value metadata. See the [module-level
224/// documentation](self) for the HTTP header mapping conventions.
225#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(default)]
227pub struct Metadata {
228    /// Internal redirect tombstone marker (header: `x-sn-redirect-tombstone`).
229    ///
230    /// When `Some(true)`, this object is a tombstone stored on the high-volume
231    /// backend indicating that the real payload lives on the long-term backend.
232    /// This field is **not** included in [`from_headers`](Metadata::from_headers)
233    /// or [`to_headers`](Metadata::to_headers) — backends handle it directly.
234    ///
235    /// **Important:** This field must remain the first field in the struct.
236    /// The BigTable backend uses a regex predicate on the serialized JSON that
237    /// assumes `is_redirect_tombstone` appears at the start of the object.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub is_redirect_tombstone: Option<bool>,
240
241    /// The expiration policy of the object (header: `x-sn-expiration`).
242    ///
243    /// Skipped during serialization when set to [`ExpirationPolicy::Manual`].
244    #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
245    pub expiration_policy: ExpirationPolicy,
246
247    /// The creation/last replacement time of the object (header: `x-sn-time-created`).
248    ///
249    /// Set by the server every time an object is put, i.e. when objects are first
250    /// created and when existing objects are overwritten.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub time_created: Option<SystemTime>,
253
254    /// The resolved expiration timestamp (header: `x-sn-time-expires`).
255    ///
256    /// Derived from the [`expiration_policy`](Self::expiration_policy). When using
257    /// a time-to-idle policy, this reflects the expiration timestamp present
258    /// *prior to* the current access to the object.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub time_expires: Option<SystemTime>,
261
262    /// IANA media type of the object (header: `Content-Type`).
263    ///
264    /// Defaults to [`DEFAULT_CONTENT_TYPE`] (`application/octet-stream`).
265    pub content_type: Cow<'static, str>,
266
267    /// The compression algorithm used for this object (header: `Content-Encoding`).
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub compression: Option<Compression>,
270
271    /// The origin of the object (header: `x-sn-origin`).
272    ///
273    /// Typically the IP address of the original source. This is an optional but
274    /// encouraged field that tracks where the payload was originally obtained
275    /// from (e.g. the IP of a Sentry SDK or CLI).
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub origin: Option<String>,
278
279    /// Size of the data in bytes, if known.
280    ///
281    /// Not transmitted via HTTP headers; set by backends when the object is
282    /// stored or retrieved.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub size: Option<usize>,
285
286    /// Arbitrary user-provided key-value metadata (header prefix: `x-snme-`).
287    ///
288    /// Each entry is transmitted as `x-snme-{key}: {value}`.
289    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
290    pub custom: BTreeMap<String, String>,
291}
292
293impl Metadata {
294    /// Extracts public API metadata from the given [`HeaderMap`].
295    ///
296    /// A prefix can be also be provided which is being stripped from custom non-standard headers.
297    /// Internal fields like `is_redirect_tombstone` are not parsed; backends handle those
298    /// separately.
299    pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
300        let mut metadata = Metadata::default();
301
302        for (name, value) in headers {
303            match *name {
304                // standard HTTP headers
305                header::CONTENT_TYPE => {
306                    let content_type = value.to_str()?;
307                    validate_content_type(content_type)?;
308                    metadata.content_type = content_type.to_owned().into();
309                }
310                header::CONTENT_ENCODING => {
311                    let compression = value.to_str()?;
312                    metadata.compression = Some(Compression::from_str(compression)?);
313                }
314                _ => {
315                    let Some(name) = name.as_str().strip_prefix(prefix) else {
316                        continue;
317                    };
318
319                    match name {
320                        // Objectstore first-class metadata
321                        HEADER_EXPIRATION => {
322                            let expiration_policy = value.to_str()?;
323                            metadata.expiration_policy =
324                                ExpirationPolicy::from_str(expiration_policy)?;
325                        }
326                        HEADER_TIME_CREATED => {
327                            let timestamp = value.to_str()?;
328                            let time = parse_rfc3339(timestamp)?;
329                            metadata.time_created = Some(time);
330                        }
331                        HEADER_TIME_EXPIRES => {
332                            let timestamp = value.to_str()?;
333                            let time = parse_rfc3339(timestamp)?;
334                            metadata.time_expires = Some(time);
335                        }
336                        HEADER_ORIGIN => {
337                            metadata.origin = Some(value.to_str()?.to_owned());
338                        }
339                        _ => {
340                            // customer-provided metadata
341                            if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
342                                let value = value.to_str()?;
343                                metadata.custom.insert(name.into(), value.into());
344                            }
345                        }
346                    }
347                }
348            }
349        }
350
351        Ok(metadata)
352    }
353
354    /// Turns the metadata into a [`HeaderMap`] for the public API.
355    ///
356    /// It will prefix any non-standard headers with the given `prefix`.
357    /// Internal fields like `is_redirect_tombstone` and GCS-specific headers are not
358    /// emitted; backends handle those separately.
359    pub fn to_headers(&self, prefix: &str) -> Result<HeaderMap, Error> {
360        let Self {
361            is_redirect_tombstone: _,
362            content_type,
363            compression,
364            origin,
365            expiration_policy,
366            time_created,
367            time_expires,
368            size: _,
369            custom,
370        } = self;
371
372        let mut headers = HeaderMap::new();
373
374        // standard headers
375        headers.append(header::CONTENT_TYPE, content_type.parse()?);
376        if let Some(compression) = compression {
377            headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
378        }
379
380        // Objectstore first-class metadata
381        if *expiration_policy != ExpirationPolicy::Manual {
382            let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
383            headers.append(name, expiration_policy.to_string().parse()?);
384        }
385        if let Some(time) = time_created {
386            let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
387            let timestamp = format_rfc3339_micros(*time);
388            headers.append(name, timestamp.to_string().parse()?);
389        }
390        if let Some(time) = time_expires {
391            let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_EXPIRES}"))?;
392            let timestamp = format_rfc3339_micros(*time);
393            headers.append(name, timestamp.to_string().parse()?);
394        }
395        if let Some(origin) = origin {
396            let name = HeaderName::try_from(format!("{prefix}{HEADER_ORIGIN}"))?;
397            headers.append(name, origin.parse()?);
398        }
399
400        // customer-provided metadata
401        for (key, value) in custom {
402            let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
403            headers.append(name, value.parse()?);
404        }
405
406        Ok(headers)
407    }
408
409    /// Returns `true` if this metadata represents a redirect tombstone.
410    pub fn is_tombstone(&self) -> bool {
411        self.is_redirect_tombstone == Some(true)
412    }
413}
414
415/// Validates that `content_type` is a valid [IANA Media
416/// Type](https://www.iana.org/assignments/media-types/media-types.xhtml).
417fn validate_content_type(content_type: &str) -> Result<(), Error> {
418    mediatype::MediaType::parse(content_type)?;
419    Ok(())
420}
421
422impl Default for Metadata {
423    fn default() -> Self {
424        Self {
425            is_redirect_tombstone: None,
426            expiration_policy: ExpirationPolicy::Manual,
427            time_created: None,
428            time_expires: None,
429            content_type: DEFAULT_CONTENT_TYPE.into(),
430            compression: None,
431            origin: None,
432            size: None,
433            custom: BTreeMap::new(),
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn from_headers_with_origin() {
444        let mut headers = HeaderMap::new();
445        headers.insert("content-type", "text/plain".parse().unwrap());
446        headers.insert(HEADER_ORIGIN, "203.0.113.42".parse().unwrap());
447
448        let metadata = Metadata::from_headers(&headers, "").unwrap();
449        assert_eq!(metadata.origin.as_deref(), Some("203.0.113.42"));
450        assert_eq!(metadata.content_type, "text/plain");
451    }
452
453    #[test]
454    fn from_headers_without_origin() {
455        let mut headers = HeaderMap::new();
456        headers.insert("content-type", "text/plain".parse().unwrap());
457
458        let metadata = Metadata::from_headers(&headers, "").unwrap();
459        assert!(metadata.origin.is_none());
460    }
461
462    #[test]
463    fn to_headers_with_origin() {
464        let metadata = Metadata {
465            origin: Some("203.0.113.42".into()),
466            ..Default::default()
467        };
468
469        let headers = metadata.to_headers("").unwrap();
470        assert_eq!(headers.get(HEADER_ORIGIN).unwrap(), "203.0.113.42");
471    }
472
473    #[test]
474    fn to_headers_without_origin() {
475        let metadata = Metadata::default();
476        let headers = metadata.to_headers("").unwrap();
477        assert!(headers.get(HEADER_ORIGIN).is_none());
478    }
479
480    #[test]
481    fn origin_header_roundtrip() {
482        let metadata = Metadata {
483            origin: Some("203.0.113.42".into()),
484            ..Default::default()
485        };
486
487        let headers = metadata.to_headers("").unwrap();
488        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
489        assert_eq!(roundtripped.origin, metadata.origin);
490    }
491
492    #[test]
493    fn from_headers_content_type_and_encoding() {
494        let mut headers = HeaderMap::new();
495        headers.insert("content-type", "application/json".parse().unwrap());
496        headers.insert("content-encoding", "zstd".parse().unwrap());
497
498        let metadata = Metadata::from_headers(&headers, "").unwrap();
499        assert_eq!(metadata.content_type, "application/json");
500        assert_eq!(metadata.compression, Some(Compression::Zstd));
501    }
502
503    #[test]
504    fn from_headers_expiration_policy() {
505        let mut headers = HeaderMap::new();
506        headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
507
508        let metadata = Metadata::from_headers(&headers, "").unwrap();
509        assert_eq!(
510            metadata.expiration_policy,
511            ExpirationPolicy::TimeToLive(Duration::from_secs(30))
512        );
513    }
514
515    #[test]
516    fn from_headers_timestamps() {
517        let mut headers = HeaderMap::new();
518        headers.insert(
519            HEADER_TIME_CREATED,
520            "2024-01-15T12:00:00.000000Z".parse().unwrap(),
521        );
522        headers.insert(
523            HEADER_TIME_EXPIRES,
524            "2024-01-16T12:00:00.000000Z".parse().unwrap(),
525        );
526
527        let metadata = Metadata::from_headers(&headers, "").unwrap();
528        assert!(metadata.time_created.is_some());
529        assert!(metadata.time_expires.is_some());
530    }
531
532    #[test]
533    fn from_headers_custom_metadata_with_prefix() {
534        let mut headers = HeaderMap::new();
535        // Simulate a backend that prefixes headers, e.g. "x-goog-meta-"
536        let prefix = "x-goog-meta-";
537        let expiration_header: HeaderName = format!("{prefix}{HEADER_EXPIRATION}").parse().unwrap();
538        headers.insert(expiration_header, "tti:1h".parse().unwrap());
539
540        let custom_header: HeaderName = format!("{prefix}{HEADER_META_PREFIX}my-key")
541            .parse()
542            .unwrap();
543        headers.insert(custom_header, "my-value".parse().unwrap());
544
545        let metadata = Metadata::from_headers(&headers, prefix).unwrap();
546        assert_eq!(
547            metadata.expiration_policy,
548            ExpirationPolicy::TimeToIdle(Duration::from_secs(3600))
549        );
550        assert_eq!(metadata.custom.get("my-key").unwrap(), "my-value");
551    }
552
553    #[test]
554    fn from_headers_invalid_content_type() {
555        let mut headers = HeaderMap::new();
556        headers.insert("content-type", "not a valid media type!".parse().unwrap());
557
558        let err = Metadata::from_headers(&headers, "").unwrap_err();
559        assert!(matches!(err, Error::ContentType(_)));
560    }
561
562    #[test]
563    fn from_headers_invalid_compression() {
564        let mut headers = HeaderMap::new();
565        headers.insert("content-encoding", "brotli".parse().unwrap());
566
567        let err = Metadata::from_headers(&headers, "").unwrap_err();
568        assert!(matches!(err, Error::Compression));
569    }
570
571    #[test]
572    fn from_headers_invalid_expiration() {
573        let mut headers = HeaderMap::new();
574        headers.insert(HEADER_EXPIRATION, "garbage".parse().unwrap());
575
576        let err = Metadata::from_headers(&headers, "").unwrap_err();
577        assert!(matches!(err, Error::Expiration(_)));
578    }
579
580    #[test]
581    fn from_headers_invalid_timestamp() {
582        let mut headers = HeaderMap::new();
583        headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
584
585        let err = Metadata::from_headers(&headers, "").unwrap_err();
586        assert!(matches!(err, Error::CreationTime(_)));
587    }
588
589    #[test]
590    fn to_headers_all_fields() {
591        let metadata = Metadata {
592            is_redirect_tombstone: None,
593            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(60)),
594            time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
595            time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_060)),
596            content_type: "text/html".into(),
597            compression: Some(Compression::Zstd),
598            origin: Some("10.0.0.1".into()),
599            size: None,
600            custom: BTreeMap::from([("foo".into(), "bar".into())]),
601        };
602
603        let headers = metadata.to_headers("pfx-").unwrap();
604        let map: BTreeMap<_, _> = headers
605            .iter()
606            .map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
607            .collect();
608
609        insta::assert_debug_snapshot!(map, @r#"
610        {
611            "content-encoding": "zstd",
612            "content-type": "text/html",
613            "pfx-x-sn-expiration": "ttl:1m",
614            "pfx-x-sn-origin": "10.0.0.1",
615            "pfx-x-sn-time-created": "2023-11-14T22:13:20.000000Z",
616            "pfx-x-sn-time-expires": "2023-11-14T22:14:20.000000Z",
617            "pfx-x-snme-foo": "bar",
618        }
619        "#);
620    }
621
622    #[test]
623    fn full_roundtrip_all_fields() {
624        let prefix = "x-test-";
625        let metadata = Metadata {
626            is_redirect_tombstone: None,
627            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(7200)),
628            time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
629            time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_007_200)),
630            content_type: "image/png".into(),
631            compression: Some(Compression::Zstd),
632            origin: Some("192.168.1.1".into()),
633            size: None,
634            custom: BTreeMap::from([
635                ("key1".into(), "value1".into()),
636                ("key2".into(), "value2".into()),
637            ]),
638        };
639
640        let headers = metadata.to_headers(prefix).unwrap();
641        let roundtripped = Metadata::from_headers(&headers, prefix).unwrap();
642
643        assert_eq!(roundtripped.expiration_policy, metadata.expiration_policy);
644        assert_eq!(roundtripped.content_type, metadata.content_type);
645        assert_eq!(roundtripped.compression, metadata.compression);
646        assert_eq!(roundtripped.origin, metadata.origin);
647        assert_eq!(roundtripped.time_created, metadata.time_created);
648        assert_eq!(roundtripped.time_expires, metadata.time_expires);
649        assert_eq!(roundtripped.custom, metadata.custom);
650    }
651
652    #[test]
653    fn from_headers_empty() {
654        let headers = HeaderMap::new();
655        let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
656        assert_eq!(metadata, Metadata::default());
657    }
658
659    #[test]
660    fn from_headers_ignores_redirect_tombstone() {
661        // Redirect tombstone is internal backend metadata, not parsed by from_headers.
662        // Backends handle it separately.
663        let mut headers = HeaderMap::new();
664        let name: HeaderName = format!("x-goog-meta-{HEADER_REDIRECT_TOMBSTONE}")
665            .parse()
666            .unwrap();
667        headers.insert(name, "true".parse().unwrap());
668
669        let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
670        assert!(metadata.is_redirect_tombstone.is_none());
671    }
672
673    #[test]
674    fn from_headers_invalid_time_expires() {
675        let mut headers = HeaderMap::new();
676        let name: HeaderName = format!("x-goog-meta-{HEADER_TIME_EXPIRES}")
677            .parse()
678            .unwrap();
679        headers.insert(name, "not-a-timestamp".parse().unwrap());
680
681        // NOTE: This produces InvalidCreationTime even for time_expires because
682        // both fields share the same humantime::TimestampError #[from] conversion.
683        assert!(Metadata::from_headers(&headers, "x-goog-meta-").is_err());
684    }
685
686    #[test]
687    fn serde_roundtrip_default() {
688        let metadata = Metadata::default();
689        let json = serde_json::to_string(&metadata).unwrap();
690        let deserialized: Metadata = serde_json::from_str(&json).unwrap();
691        assert_eq!(deserialized, metadata);
692    }
693
694    #[test]
695    fn serde_roundtrip_all_fields() {
696        let metadata = Metadata {
697            is_redirect_tombstone: Some(true),
698            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
699            time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
700            time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_003_600)),
701            content_type: "application/json".into(),
702            compression: Some(Compression::Zstd),
703            origin: Some("10.0.0.1".into()),
704            size: Some(1024),
705            custom: BTreeMap::from([("key".into(), "value".into())]),
706        };
707
708        let json = serde_json::to_string(&metadata).unwrap();
709        let deserialized: Metadata = serde_json::from_str(&json).unwrap();
710        assert_eq!(deserialized, metadata);
711    }
712
713    #[test]
714    fn size_not_included_in_headers() {
715        let metadata = Metadata {
716            size: Some(42),
717            ..Default::default()
718        };
719
720        let headers = metadata.to_headers("x-goog-meta-").unwrap();
721        let has_size_header = headers.keys().any(|k| k.as_str().contains("size"));
722        assert!(!has_size_header);
723    }
724
725    #[test]
726    fn default_metadata() {
727        let metadata = Metadata::default();
728        assert_eq!(metadata.content_type, DEFAULT_CONTENT_TYPE);
729        assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
730        assert!(metadata.compression.is_none());
731        assert!(metadata.origin.is_none());
732        assert!(metadata.time_created.is_none());
733        assert!(metadata.time_expires.is_none());
734        assert!(metadata.is_redirect_tombstone.is_none());
735        assert!(metadata.size.is_none());
736        assert!(metadata.custom.is_empty());
737    }
738
739    #[test]
740    fn expiration_display_roundtrip() {
741        let cases = [
742            ExpirationPolicy::Manual,
743            ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
744            ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
745        ];
746
747        for policy in cases {
748            let displayed = policy.to_string();
749            let parsed: ExpirationPolicy = displayed.parse().unwrap();
750            assert_eq!(parsed, policy);
751        }
752    }
753
754    #[test]
755    fn expiration_parse_invalid() {
756        assert!(ExpirationPolicy::from_str("garbage").is_err());
757        assert!(ExpirationPolicy::from_str("ttl:").is_err());
758        assert!(ExpirationPolicy::from_str("").is_err());
759    }
760
761    #[test]
762    fn expiration_policy_helpers() {
763        assert_eq!(ExpirationPolicy::Manual.expires_in(), None);
764        assert!(ExpirationPolicy::Manual.is_manual());
765        assert!(!ExpirationPolicy::Manual.is_timeout());
766
767        let ttl = ExpirationPolicy::TimeToLive(Duration::from_secs(60));
768        assert_eq!(ttl.expires_in(), Some(Duration::from_secs(60)));
769        assert!(ttl.is_timeout());
770        assert!(!ttl.is_manual());
771
772        let tti = ExpirationPolicy::TimeToIdle(Duration::from_secs(120));
773        assert_eq!(tti.expires_in(), Some(Duration::from_secs(120)));
774        assert!(tti.is_timeout());
775        assert!(!tti.is_manual());
776    }
777
778    #[test]
779    fn compression_display_roundtrip() {
780        let displayed = Compression::Zstd.to_string();
781        assert_eq!(displayed, "zstd");
782        let parsed: Compression = displayed.parse().unwrap();
783        assert_eq!(parsed, Compression::Zstd);
784    }
785
786    #[test]
787    fn compression_parse_invalid() {
788        assert!(Compression::from_str("gzip").is_err());
789        assert!(Compression::from_str("").is_err());
790    }
791}