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