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