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//!
33//! # Escaping free-form values
34//!
35//! [`Metadata`] always holds logical strings: [`filename`](Metadata::filename),
36//! [`origin`](Metadata::origin), and [`custom`](Metadata::custom) values may
37//! contain arbitrary Unicode.
38//!
39//! Over the wire, metadata travels in HTTP headers, which have no charset. In
40//! practice anything outside visible ASCII is either rejected outright or
41//! silently reinterpreted.
42//!
43//! The fields are therefore percent-encoded in headers, via
44//! [`headers::encode_header_value`] and [`headers::decode_header_value`].
45//! Encoding is a property of the *transport*, never of the stored value:
46//! anything reading [`Metadata`] sees the logical string.
47
48use std::borrow::Cow;
49use std::collections::BTreeMap;
50use std::fmt;
51use std::num::ParseIntError;
52use std::str::FromStr;
53use std::time::{Duration, SystemTime};
54
55use http::header::{self, HeaderMap, HeaderName};
56use humantime::{format_rfc3339_micros, parse_rfc3339};
57use serde::{Deserialize, Serialize};
58
59use crate::duration::{ParseDurationError, format_duration, parse_duration};
60use crate::headers;
61
62/// The custom HTTP header that contains the serialized [`ExpirationPolicy`].
63pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
64/// The custom HTTP header that contains the object creation time.
65pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
66/// The custom HTTP header that contains the object expiration time.
67pub const HEADER_TIME_EXPIRES: &str = "x-sn-time-expires";
68/// The custom HTTP header that contains the origin of the object.
69pub const HEADER_ORIGIN: &str = "x-sn-origin";
70/// The custom HTTP header that contains the filename of the object.
71pub const HEADER_FILENAME: &str = "x-sn-filename";
72/// The custom HTTP header that contains the size of the stored object in bytes.
73pub const HEADER_SIZE: &str = "x-sn-size";
74/// The prefix for custom HTTP headers containing custom per-object metadata.
75pub const HEADER_META_PREFIX: &str = "x-snme-";
76
77/// The default content type for objects without a known content type.
78pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
79
80/// Upper bound on the TTI debounce window.
81///
82/// The debounce window for TTI bumps is `min(tti / 4, MAX_TTI_DEBOUNCE)`. For
83/// TTI values above 4 days the debounce stays at 24 hours (the historical
84/// constant); shorter TTI values get a proportionally smaller window so that
85/// bumps are not silently suppressed.
86const MAX_TTI_DEBOUNCE: Duration = Duration::from_hours(24);
87
88/// Errors that can happen dealing with metadata
89#[derive(Debug, thiserror::Error)]
90pub enum Error {
91    /// Any problems dealing with http headers, essentially converting to/from [`str`].
92    #[error("error dealing with http headers")]
93    Header(#[from] Option<http::Error>),
94    /// The value for the expiration policy is invalid.
95    #[error("invalid expiration policy value")]
96    Expiration(#[from] Option<ParseDurationError>),
97    /// The compression algorithm is invalid.
98    #[error("invalid compression value")]
99    Compression,
100    /// The content type is invalid.
101    #[error("invalid content type")]
102    ContentType(#[from] mediatype::MediaTypeError),
103    /// The creation time is invalid.
104    #[error("invalid creation time")]
105    CreationTime(#[from] humantime::TimestampError),
106    /// The object size is not a valid byte count.
107    #[error("invalid object size")]
108    Size(#[from] ParseIntError),
109    /// A free-form header value did not decode into a logical string.
110    #[error("invalid metadata header value")]
111    Encoding(#[from] crate::headers::DecodeError),
112    /// An internal consistency invariant on the metadata was violated.
113    #[error("invariant violation: {0}")]
114    Invariant(&'static str),
115}
116impl From<header::InvalidHeaderValue> for Error {
117    fn from(err: header::InvalidHeaderValue) -> Self {
118        Self::Header(Some(err.into()))
119    }
120}
121impl From<header::InvalidHeaderName> for Error {
122    fn from(err: header::InvalidHeaderName) -> Self {
123        Self::Header(Some(err.into()))
124    }
125}
126impl From<header::ToStrError> for Error {
127    fn from(_err: header::ToStrError) -> Self {
128        // the error happens when converting a header value back to a `str`
129        Self::Header(None)
130    }
131}
132
133/// The per-object expiration policy.
134///
135/// Controls automatic object cleanup. The policy is set by the client at upload
136/// time via the [`x-sn-expiration`](HEADER_EXPIRATION) header and persisted with
137/// the object.
138///
139/// | Variant      | Wire format | Behavior                                     |
140/// |--------------|-------------|----------------------------------------------|
141/// | `Manual`     | `manual`    | No automatic expiration (default)            |
142/// | `TimeToLive` | `ttl:30s`   | Expires after a fixed duration from creation |
143/// | `TimeToIdle` | `tti:1h`    | Expires after a duration of no access        |
144///
145/// Durations use the [wire format](crate::duration), which is written in days, hours, minutes,
146/// and seconds (e.g. `30s`, `5m`, `1h`, `7d`, `400d 12h`).
147///
148/// **Important:** `Manual` is the default and must remain so — persisted objects
149/// without an explicit policy are deserialized as `Manual`.
150#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
151pub enum ExpirationPolicy {
152    /// Manual expiration, meaning no automatic cleanup.
153    // IMPORTANT: Do not change the default, we rely on this for persisted objects.
154    #[default]
155    Manual,
156    /// Time to live, with expiration after the specified duration.
157    TimeToLive(Duration),
158    /// Time to idle, with expiration once the object has not been accessed within the specified duration.
159    TimeToIdle(Duration),
160}
161impl ExpirationPolicy {
162    /// Returns the duration after which the object expires.
163    pub fn expires_in(&self) -> Option<Duration> {
164        match self {
165            ExpirationPolicy::Manual => None,
166            ExpirationPolicy::TimeToLive(duration) => Some(*duration),
167            ExpirationPolicy::TimeToIdle(duration) => Some(*duration),
168        }
169    }
170
171    /// Returns `true` if this policy indicates time-based expiry.
172    pub fn is_timeout(&self) -> bool {
173        match self {
174            ExpirationPolicy::TimeToLive(_) => true,
175            ExpirationPolicy::TimeToIdle(_) => true,
176            ExpirationPolicy::Manual => false,
177        }
178    }
179
180    /// Returns `true` if this policy is `Manual`.
181    pub fn is_manual(&self) -> bool {
182        *self == ExpirationPolicy::Manual
183    }
184
185    /// Checks whether a TTI deadline needs bumping given the current expiry and access time.
186    ///
187    /// Returns `Some(new_expire_at)` when the current deadline is stale enough
188    /// to justify a write, `None` otherwise. The debounce window scales with the
189    /// TTI duration so short-TTI objects get bumped more frequently.
190    pub fn check_tti_bump(
191        &self,
192        time_expires: Option<SystemTime>,
193        access_time: SystemTime,
194    ) -> Option<SystemTime> {
195        let ExpirationPolicy::TimeToIdle(tti) = *self else {
196            return None;
197        };
198
199        let new_expire_at = access_time + tti;
200        let debounce = (tti / 4).min(MAX_TTI_DEBOUNCE);
201        match time_expires {
202            Some(ts) if ts < new_expire_at - debounce => Some(new_expire_at),
203            _ => None,
204        }
205    }
206}
207impl fmt::Display for ExpirationPolicy {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            ExpirationPolicy::TimeToLive(duration) => {
211                write!(f, "ttl:{}", format_duration(*duration))
212            }
213            ExpirationPolicy::TimeToIdle(duration) => {
214                write!(f, "tti:{}", format_duration(*duration))
215            }
216            ExpirationPolicy::Manual => f.write_str("manual"),
217        }
218    }
219}
220impl FromStr for ExpirationPolicy {
221    type Err = Error;
222
223    fn from_str(s: &str) -> Result<Self, Self::Err> {
224        if s == "manual" {
225            return Ok(ExpirationPolicy::Manual);
226        }
227        if let Some(duration) = s.strip_prefix("ttl:") {
228            return Ok(ExpirationPolicy::TimeToLive(parse_duration(duration)?));
229        }
230        if let Some(duration) = s.strip_prefix("tti:") {
231            return Ok(ExpirationPolicy::TimeToIdle(parse_duration(duration)?));
232        }
233        Err(Error::Expiration(None))
234    }
235}
236
237/// The compression algorithm applied to an object's payload.
238///
239/// Transmitted via the standard `Content-Encoding` HTTP header. Currently only
240/// Zstandard (`zstd`) is supported.
241#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
242pub enum Compression {
243    /// Compressed using `zstd`.
244    Zstd,
245    // /// Compressed using `gzip`.
246    // Gzip,
247    // /// Compressed using `lz4`.
248    // Lz4,
249}
250
251impl Compression {
252    /// Returns a string representation of the compression algorithm.
253    pub fn as_str(&self) -> &str {
254        match self {
255            Compression::Zstd => "zstd",
256            // Compression::Gzip => "gzip",
257            // Compression::Lz4 => "lz4",
258        }
259    }
260}
261
262impl fmt::Display for Compression {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        f.write_str(self.as_str())
265    }
266}
267
268impl FromStr for Compression {
269    type Err = Error;
270
271    fn from_str(s: &str) -> Result<Self, Self::Err> {
272        match s {
273            "zstd" => Ok(Compression::Zstd),
274            // "gzip" => Compression::Gzip,
275            // "lz4" => Compression::Lz4,
276            _ => Err(Error::Compression),
277        }
278    }
279}
280
281/// Per-object metadata.
282///
283/// Includes first-class fields (expiration, compression, timestamps, etc.) and
284/// arbitrary user-provided key-value metadata. See the [module-level
285/// documentation](self) for the HTTP header mapping conventions.
286#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
287#[serde(default)]
288pub struct Metadata {
289    /// The expiration policy of the object (header: `x-sn-expiration`).
290    ///
291    /// Skipped during serialization when set to [`ExpirationPolicy::Manual`].
292    #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
293    pub expiration_policy: ExpirationPolicy,
294
295    /// The creation/last replacement time of the object (header: `x-sn-time-created`).
296    ///
297    /// Set by the server every time an object is put, i.e. when objects are first
298    /// created and when existing objects are overwritten.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub time_created: Option<SystemTime>,
301
302    /// The resolved expiration timestamp (header: `x-sn-time-expires`).
303    ///
304    /// Derived from the [`expiration_policy`](Self::expiration_policy). When using
305    /// a time-to-idle policy, this reflects the expiration timestamp present
306    /// *prior to* the current access to the object.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub time_expires: Option<SystemTime>,
309
310    /// IANA media type of the object (header: `Content-Type`).
311    ///
312    /// Defaults to [`DEFAULT_CONTENT_TYPE`] (`application/octet-stream`).
313    pub content_type: Cow<'static, str>,
314
315    /// The compression algorithm used for this object (header: `Content-Encoding`).
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub compression: Option<Compression>,
318
319    /// The origin of the object (header: `x-sn-origin`).
320    ///
321    /// Typically the IP address of the original source. This is an optional but
322    /// encouraged field that tracks where the payload was originally obtained
323    /// from (e.g. the IP of a Sentry SDK or CLI).
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub origin: Option<String>,
326
327    /// An optional filename associated with this object (header: `x-sn-filename`).
328    ///
329    /// When present, the server includes a `Content-Disposition: attachment; filename="<filename>"`
330    /// header in GET responses, prompting browsers and download tools to save the file
331    /// under this name. Non-ASCII filenames additionally get an RFC 8187 `filename*` parameter.
332    ///
333    /// This is a logical string and may contain arbitrary Unicode; it is escaped only on the
334    /// wire (see [the module docs](self#escaping-free-form-values)).
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub filename: Option<String>,
337
338    /// Size of the stored data in bytes, if known (header: `x-sn-size`).
339    ///
340    /// Read-only. This is the size of the complete object, even when only a range of it is
341    /// being returned. It describes the stored bytes, so for a compressed object it is the
342    /// compressed size.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub size: Option<usize>,
345
346    /// Arbitrary user-provided key-value metadata (header prefix: `x-snme-`).
347    ///
348    /// Each entry is transmitted as `x-snme-{key}: {value}`.
349    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
350    pub custom: BTreeMap<String, String>,
351}
352
353impl Metadata {
354    /// Parses the metadata headers accepted from writing endpoints.
355    ///
356    /// Unlike [`from_headers`](Self::from_headers), this skips parsing read-only attributes so
357    /// clients cannot set them via headers.
358    ///
359    /// This materializes the following attributes:
360    /// - [`time_created`](Self::time_created)
361    /// - [`time_expires`](Self::time_expires)
362    ///
363    /// A prefix can also be provided which is stripped from custom non-standard headers.
364    pub fn from_insert_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
365        let mut metadata = Self::parse_headers(headers, prefix, true)?;
366
367        let now = SystemTime::now();
368        metadata.time_created = Some(now);
369        metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
370
371        Ok(metadata)
372    }
373
374    /// Validates internal consistency of the metadata.
375    ///
376    /// A time-based [`expiration_policy`](Self::expiration_policy) must carry a resolved
377    /// [`time_expires`](Self::time_expires); backends rely on this to persist a concrete
378    /// expiration.
379    pub fn validate(&self) -> Result<(), Error> {
380        if self.expiration_policy.is_timeout() && self.time_expires.is_none() {
381            return Err(Error::Invariant(
382                "expiration policy requires a resolved expiration time",
383            ));
384        }
385        Ok(())
386    }
387
388    /// Checks whether this object's TTI deadline needs bumping.
389    ///
390    /// See [`ExpirationPolicy::check_tti_bump`] for details.
391    pub fn check_tti_bump(&self, access_time: SystemTime) -> Option<SystemTime> {
392        self.expiration_policy
393            .check_tti_bump(self.time_expires, access_time)
394    }
395
396    /// Extracts public API metadata from the given [`HeaderMap`].
397    ///
398    /// A prefix can be also be provided which is being stripped from custom non-standard headers.
399    pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
400        Self::parse_headers(headers, prefix, false)
401    }
402
403    /// Parses metadata from the given [`HeaderMap`].
404    ///
405    /// When `skip_read_only` is set, read-only attributes are not parsed off the headers, so a
406    /// malformed client-supplied value cannot fail the parse. A prefix can also be provided which
407    /// is stripped from custom non-standard headers.
408    fn parse_headers(
409        headers: &HeaderMap,
410        prefix: &str,
411        skip_read_only: bool,
412    ) -> Result<Self, Error> {
413        let mut metadata = Metadata::default();
414
415        for (name, value) in headers {
416            match *name {
417                // standard HTTP headers
418                header::CONTENT_TYPE => {
419                    let content_type = value.to_str()?;
420                    validate_content_type(content_type)?;
421                    metadata.content_type = content_type.to_owned().into();
422                }
423                header::CONTENT_ENCODING => {
424                    let compression = value.to_str()?;
425                    metadata.compression = Some(Compression::from_str(compression)?);
426                }
427                _ => {
428                    let Some(name) = name.as_str().strip_prefix(prefix) else {
429                        continue;
430                    };
431
432                    match name {
433                        // Objectstore first-class metadata
434                        HEADER_EXPIRATION => {
435                            let expiration_policy = value.to_str()?;
436                            metadata.expiration_policy =
437                                ExpirationPolicy::from_str(expiration_policy)?;
438                        }
439                        HEADER_TIME_CREATED if !skip_read_only => {
440                            let timestamp = value.to_str()?;
441                            let time = parse_rfc3339(timestamp)?;
442                            metadata.time_created = Some(time);
443                        }
444                        HEADER_TIME_EXPIRES if !skip_read_only => {
445                            let timestamp = value.to_str()?;
446                            let time = parse_rfc3339(timestamp)?;
447                            metadata.time_expires = Some(time);
448                        }
449                        HEADER_ORIGIN => {
450                            metadata.origin = Some(headers::decode_header_value(value)?);
451                        }
452                        HEADER_FILENAME => {
453                            metadata.filename = Some(headers::decode_header_value(value)?);
454                        }
455                        HEADER_SIZE if !skip_read_only => {
456                            let size = value.to_str()?;
457                            metadata.size = Some(size.parse()?);
458                        }
459                        _ => {
460                            // customer-provided metadata
461                            if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
462                                let value = headers::decode_header_value(value)?;
463                                metadata.custom.insert(name.into(), value);
464                            }
465                        }
466                    }
467                }
468            }
469        }
470
471        Ok(metadata)
472    }
473
474    /// Turns the metadata into a [`HeaderMap`] for the public API.
475    ///
476    /// It will prefix any non-standard headers with the given `prefix`. GCS-specific headers are
477    /// not emitted; backends handle those separately.
478    pub fn to_headers(&self, prefix: &str) -> Result<HeaderMap, Error> {
479        let Self {
480            content_type,
481            compression,
482            origin,
483            filename,
484            expiration_policy,
485            time_created,
486            time_expires,
487            size,
488            custom,
489        } = self;
490
491        let mut headers = HeaderMap::new();
492
493        // standard headers
494        headers.append(header::CONTENT_TYPE, content_type.parse()?);
495        if let Some(compression) = compression {
496            headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
497        }
498
499        // Objectstore first-class metadata
500        if *expiration_policy != ExpirationPolicy::Manual {
501            let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
502            headers.append(name, expiration_policy.to_string().parse()?);
503        }
504        if let Some(time) = time_created {
505            let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
506            let timestamp = format_rfc3339_micros(*time);
507            headers.append(name, timestamp.to_string().parse()?);
508        }
509        if let Some(time) = time_expires {
510            let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_EXPIRES}"))?;
511            let timestamp = format_rfc3339_micros(*time);
512            headers.append(name, timestamp.to_string().parse()?);
513        }
514        if let Some(origin) = origin {
515            let name = HeaderName::try_from(format!("{prefix}{HEADER_ORIGIN}"))?;
516            headers.append(name, headers::encode_header_value(origin));
517        }
518        if let Some(filename) = filename {
519            let name = HeaderName::try_from(format!("{prefix}{HEADER_FILENAME}"))?;
520            headers.append(name, headers::encode_header_value(filename));
521        }
522        if let Some(size) = size {
523            let name = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
524            headers.append(name, size.to_string().parse()?);
525        }
526
527        // customer-provided metadata
528        for (key, value) in custom {
529            let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
530            headers.append(name, headers::encode_header_value(value));
531        }
532
533        Ok(headers)
534    }
535}
536
537/// Validates that `content_type` is a valid [IANA Media
538/// Type](https://www.iana.org/assignments/media-types/media-types.xhtml).
539fn validate_content_type(content_type: &str) -> Result<(), Error> {
540    mediatype::MediaType::parse(content_type)?;
541    Ok(())
542}
543
544impl Default for Metadata {
545    fn default() -> Self {
546        Self {
547            expiration_policy: ExpirationPolicy::Manual,
548            time_created: None,
549            time_expires: None,
550            content_type: DEFAULT_CONTENT_TYPE.into(),
551            compression: None,
552            origin: None,
553            filename: None,
554            size: None,
555            custom: BTreeMap::new(),
556        }
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn from_headers_with_origin() {
566        let mut headers = HeaderMap::new();
567        headers.insert("content-type", "text/plain".parse().unwrap());
568        headers.insert(HEADER_ORIGIN, "203.0.113.42".parse().unwrap());
569
570        let metadata = Metadata::from_headers(&headers, "").unwrap();
571        assert_eq!(metadata.origin.as_deref(), Some("203.0.113.42"));
572        assert_eq!(metadata.content_type, "text/plain");
573    }
574
575    #[test]
576    fn from_headers_without_origin() {
577        let mut headers = HeaderMap::new();
578        headers.insert("content-type", "text/plain".parse().unwrap());
579
580        let metadata = Metadata::from_headers(&headers, "").unwrap();
581        assert!(metadata.origin.is_none());
582    }
583
584    #[test]
585    fn to_headers_with_origin() {
586        let metadata = Metadata {
587            origin: Some("203.0.113.42".into()),
588            ..Default::default()
589        };
590
591        let headers = metadata.to_headers("").unwrap();
592        assert_eq!(headers.get(HEADER_ORIGIN).unwrap(), "203.0.113.42");
593    }
594
595    #[test]
596    fn to_headers_without_origin() {
597        let metadata = Metadata::default();
598        let headers = metadata.to_headers("").unwrap();
599        assert!(headers.get(HEADER_ORIGIN).is_none());
600    }
601
602    #[test]
603    fn origin_header_roundtrip() {
604        let metadata = Metadata {
605            origin: Some("203.0.113.42".into()),
606            ..Default::default()
607        };
608
609        let headers = metadata.to_headers("").unwrap();
610        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
611        assert_eq!(roundtripped.origin, metadata.origin);
612    }
613
614    #[test]
615    fn from_headers_with_filename() {
616        let mut headers = HeaderMap::new();
617        headers.insert(HEADER_FILENAME, "report.pdf".parse().unwrap());
618
619        let metadata = Metadata::from_headers(&headers, "").unwrap();
620        assert_eq!(metadata.filename.as_deref(), Some("report.pdf"));
621    }
622
623    #[test]
624    fn from_headers_without_filename() {
625        let headers = HeaderMap::new();
626        let metadata = Metadata::from_headers(&headers, "").unwrap();
627        assert!(metadata.filename.is_none());
628    }
629
630    #[test]
631    fn to_headers_with_filename() {
632        let metadata = Metadata {
633            filename: Some("report.pdf".into()),
634            ..Default::default()
635        };
636
637        let headers = metadata.to_headers("").unwrap();
638        assert_eq!(headers.get(HEADER_FILENAME).unwrap(), "report.pdf");
639    }
640
641    #[test]
642    fn to_headers_without_filename() {
643        let metadata = Metadata::default();
644        let headers = metadata.to_headers("").unwrap();
645        assert!(headers.get(HEADER_FILENAME).is_none());
646    }
647
648    #[test]
649    fn filename_header_roundtrip() {
650        let metadata = Metadata {
651            filename: Some("report.pdf".into()),
652            ..Default::default()
653        };
654
655        let headers = metadata.to_headers("").unwrap();
656        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
657        assert_eq!(roundtripped.filename, metadata.filename);
658    }
659
660    /// Every free-form field is escaped on the way out and decoded on the way back in.
661    ///
662    /// The escaping itself is covered in [`crate::headers`]; this only pins down that each of the
663    /// three fields that needs it actually goes through it, in both directions.
664    #[test]
665    fn free_form_values_are_escaped_on_the_wire() {
666        let metadata = Metadata {
667            origin: Some("Ünknown-源".into()),
668            filename: Some("réport-📄.pdf".into()),
669            custom: BTreeMap::from([("release".to_owned(), "100% vérsion-🚀".to_owned())]),
670            ..Default::default()
671        };
672
673        let headers = metadata.to_headers("").unwrap();
674        assert_eq!(
675            headers.get(HEADER_ORIGIN).unwrap(),
676            "%C3%9Cnknown-%E6%BA%90"
677        );
678        assert_eq!(
679            headers.get(HEADER_FILENAME).unwrap(),
680            "r%C3%A9port-%F0%9F%93%84.pdf",
681        );
682        assert_eq!(
683            headers.get(format!("{HEADER_META_PREFIX}release")).unwrap(),
684            "100%25 v%C3%A9rsion-%F0%9F%9A%80",
685        );
686
687        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
688        assert_eq!(roundtripped.origin, metadata.origin);
689        assert_eq!(roundtripped.filename, metadata.filename);
690        assert_eq!(roundtripped.custom, metadata.custom);
691    }
692
693    #[test]
694    fn from_headers_content_type_and_encoding() {
695        let mut headers = HeaderMap::new();
696        headers.insert("content-type", "application/json".parse().unwrap());
697        headers.insert("content-encoding", "zstd".parse().unwrap());
698
699        let metadata = Metadata::from_headers(&headers, "").unwrap();
700        assert_eq!(metadata.content_type, "application/json");
701        assert_eq!(metadata.compression, Some(Compression::Zstd));
702    }
703
704    #[test]
705    fn from_headers_expiration_policy() {
706        let mut headers = HeaderMap::new();
707        headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
708
709        let metadata = Metadata::from_headers(&headers, "").unwrap();
710        assert_eq!(
711            metadata.expiration_policy,
712            ExpirationPolicy::TimeToLive(Duration::from_secs(30))
713        );
714    }
715
716    #[test]
717    fn expiration_policy_keeps_long_durations_in_days() {
718        let ttl = Duration::from_secs(400 * 86400 + 3600);
719        let policy = ExpirationPolicy::TimeToLive(ttl);
720
721        assert_eq!(policy.to_string(), "ttl:400d 1h");
722        assert_eq!(
723            policy.to_string().parse::<ExpirationPolicy>().unwrap(),
724            policy
725        );
726    }
727
728    #[test]
729    fn expiration_policy_parses_units_that_are_never_emitted() {
730        let policy: ExpirationPolicy = "tti:2weeks".parse().unwrap();
731        assert_eq!(
732            policy,
733            ExpirationPolicy::TimeToIdle(Duration::from_secs(14 * 86400))
734        );
735        // Re-emitting normalizes to the units of the wire format.
736        assert_eq!(policy.to_string(), "tti:14d");
737    }
738
739    #[test]
740    fn from_headers_timestamps() {
741        let mut headers = HeaderMap::new();
742        headers.insert(
743            HEADER_TIME_CREATED,
744            "2024-01-15T12:00:00.000000Z".parse().unwrap(),
745        );
746        headers.insert(
747            HEADER_TIME_EXPIRES,
748            "2024-01-16T12:00:00.000000Z".parse().unwrap(),
749        );
750
751        let metadata = Metadata::from_headers(&headers, "").unwrap();
752        assert!(metadata.time_created.is_some());
753        assert!(metadata.time_expires.is_some());
754    }
755
756    #[test]
757    fn from_insert_headers_ignores_read_only_fields() {
758        // Read-only and output attributes must never be taken from an untrusted
759        // client request, even if the client supplies the headers.
760        let forged_created = "2024-01-15T12:00:00.000000Z";
761        let mut headers = HeaderMap::new();
762        headers.insert("content-type", "text/plain".parse().unwrap());
763        headers.insert(HEADER_TIME_CREATED, forged_created.parse().unwrap());
764        headers.insert(
765            HEADER_TIME_EXPIRES,
766            "2024-01-16T12:00:00.000000Z".parse().unwrap(),
767        );
768
769        let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
770        // `time_created` is stamped by the server, not the client's forged value.
771        let created = metadata.time_created.unwrap();
772        assert_ne!(created, parse_rfc3339(forged_created).unwrap());
773        assert!(metadata.time_expires.is_none());
774        assert!(metadata.size.is_none());
775        // Client-settable fields are still parsed.
776        assert_eq!(metadata.content_type, "text/plain");
777    }
778
779    #[test]
780    fn from_insert_headers_ignores_malformed_read_only_fields() {
781        // A malformed read-only header must not fail the write: it is skipped, not parsed.
782        let mut headers = HeaderMap::new();
783        headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
784        headers.insert(HEADER_TIME_EXPIRES, "not-a-timestamp".parse().unwrap());
785
786        let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
787        assert!(metadata.time_created.is_some());
788        assert!(metadata.time_expires.is_none());
789    }
790
791    #[test]
792    fn from_insert_headers_resolves_time_expires_for_ttl() {
793        let mut headers = HeaderMap::new();
794        headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
795
796        let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
797        let created = metadata.time_created.unwrap();
798        let expires = metadata.time_expires.unwrap();
799        // Both timestamps derive from the same `now`, so the expiry is exact.
800        assert_eq!(expires, created + Duration::from_secs(30));
801    }
802
803    #[test]
804    fn from_insert_headers_resolves_time_expires_for_tti() {
805        let mut headers = HeaderMap::new();
806        headers.insert(HEADER_EXPIRATION, "tti:1h".parse().unwrap());
807
808        let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
809        let created = metadata.time_created.unwrap();
810        let expires = metadata.time_expires.unwrap();
811        assert_eq!(expires, created + Duration::from_hours(1));
812    }
813
814    #[test]
815    fn from_insert_headers_manual_leaves_time_expires_none() {
816        let headers = HeaderMap::new();
817        let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
818        assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
819        assert!(metadata.time_expires.is_none());
820    }
821
822    #[test]
823    fn validate_accepts_resolved_timeout() {
824        let metadata = Metadata {
825            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
826            time_expires: Some(SystemTime::now() + Duration::from_secs(30)),
827            ..Default::default()
828        };
829        assert!(metadata.validate().is_ok());
830    }
831
832    #[test]
833    fn validate_accepts_manual_without_expiry() {
834        let metadata = Metadata::default();
835        assert!(metadata.validate().is_ok());
836    }
837
838    #[test]
839    fn validate_rejects_timeout_without_expiry() {
840        let metadata = Metadata {
841            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
842            time_expires: None,
843            ..Default::default()
844        };
845        assert!(matches!(metadata.validate(), Err(Error::Invariant(_))));
846    }
847
848    #[test]
849    fn from_headers_custom_metadata_with_prefix() {
850        let mut headers = HeaderMap::new();
851        // Simulate a backend that prefixes headers, e.g. "x-goog-meta-"
852        let prefix = "x-goog-meta-";
853        let expiration_header: HeaderName = format!("{prefix}{HEADER_EXPIRATION}").parse().unwrap();
854        headers.insert(expiration_header, "tti:1h".parse().unwrap());
855
856        let custom_header: HeaderName = format!("{prefix}{HEADER_META_PREFIX}my-key")
857            .parse()
858            .unwrap();
859        headers.insert(custom_header, "my-value".parse().unwrap());
860
861        let metadata = Metadata::from_headers(&headers, prefix).unwrap();
862        assert_eq!(
863            metadata.expiration_policy,
864            ExpirationPolicy::TimeToIdle(Duration::from_hours(1))
865        );
866        assert_eq!(metadata.custom.get("my-key").unwrap(), "my-value");
867    }
868
869    #[test]
870    fn from_headers_invalid_content_type() {
871        let mut headers = HeaderMap::new();
872        headers.insert("content-type", "not a valid media type!".parse().unwrap());
873
874        let err = Metadata::from_headers(&headers, "").unwrap_err();
875        assert!(matches!(err, Error::ContentType(_)));
876    }
877
878    #[test]
879    fn from_headers_invalid_compression() {
880        let mut headers = HeaderMap::new();
881        headers.insert("content-encoding", "brotli".parse().unwrap());
882
883        let err = Metadata::from_headers(&headers, "").unwrap_err();
884        assert!(matches!(err, Error::Compression));
885    }
886
887    #[test]
888    fn from_headers_invalid_expiration() {
889        let mut headers = HeaderMap::new();
890        headers.insert(HEADER_EXPIRATION, "garbage".parse().unwrap());
891
892        let err = Metadata::from_headers(&headers, "").unwrap_err();
893        assert!(matches!(err, Error::Expiration(_)));
894    }
895
896    #[test]
897    fn from_headers_invalid_timestamp() {
898        let mut headers = HeaderMap::new();
899        headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
900
901        let err = Metadata::from_headers(&headers, "").unwrap_err();
902        assert!(matches!(err, Error::CreationTime(_)));
903    }
904
905    #[test]
906    fn to_headers_all_fields() {
907        let metadata = Metadata {
908            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_mins(1)),
909            time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
910            time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_060)),
911            content_type: "text/html".into(),
912            compression: Some(Compression::Zstd),
913            origin: Some("10.0.0.1".into()),
914            filename: Some("report.pdf".into()),
915            size: None,
916            custom: BTreeMap::from([("foo".into(), "bar".into())]),
917        };
918
919        let headers = metadata.to_headers("pfx-").unwrap();
920        let map: BTreeMap<_, _> = headers
921            .iter()
922            .map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
923            .collect();
924
925        insta::assert_debug_snapshot!(map, @r#"
926        {
927            "content-encoding": "zstd",
928            "content-type": "text/html",
929            "pfx-x-sn-expiration": "ttl:1m",
930            "pfx-x-sn-filename": "report.pdf",
931            "pfx-x-sn-origin": "10.0.0.1",
932            "pfx-x-sn-time-created": "2023-11-14T22:13:20.000000Z",
933            "pfx-x-sn-time-expires": "2023-11-14T22:14:20.000000Z",
934            "pfx-x-snme-foo": "bar",
935        }
936        "#);
937    }
938
939    #[test]
940    fn full_roundtrip_all_fields() {
941        let prefix = "x-test-";
942        let metadata = Metadata {
943            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(2)),
944            time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
945            time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_007_200)),
946            content_type: "image/png".into(),
947            compression: Some(Compression::Zstd),
948            origin: Some("192.168.1.1".into()),
949            filename: Some("image.png".into()),
950            size: None,
951            custom: BTreeMap::from([
952                ("key1".into(), "value1".into()),
953                ("key2".into(), "value2".into()),
954            ]),
955        };
956
957        let headers = metadata.to_headers(prefix).unwrap();
958        let roundtripped = Metadata::from_headers(&headers, prefix).unwrap();
959
960        assert_eq!(roundtripped.expiration_policy, metadata.expiration_policy);
961        assert_eq!(roundtripped.content_type, metadata.content_type);
962        assert_eq!(roundtripped.compression, metadata.compression);
963        assert_eq!(roundtripped.origin, metadata.origin);
964        assert_eq!(roundtripped.filename, metadata.filename);
965        assert_eq!(roundtripped.time_created, metadata.time_created);
966        assert_eq!(roundtripped.time_expires, metadata.time_expires);
967        assert_eq!(roundtripped.custom, metadata.custom);
968    }
969
970    #[test]
971    fn from_headers_empty() {
972        let headers = HeaderMap::new();
973        let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
974        assert_eq!(metadata, Metadata::default());
975    }
976
977    #[test]
978    fn from_headers_invalid_time_expires() {
979        let mut headers = HeaderMap::new();
980        let name: HeaderName = format!("x-goog-meta-{HEADER_TIME_EXPIRES}")
981            .parse()
982            .unwrap();
983        headers.insert(name, "not-a-timestamp".parse().unwrap());
984
985        // NOTE: This produces InvalidCreationTime even for time_expires because
986        // both fields share the same humantime::TimestampError #[from] conversion.
987        assert!(Metadata::from_headers(&headers, "x-goog-meta-").is_err());
988    }
989
990    #[test]
991    fn serde_roundtrip_default() {
992        let metadata = Metadata::default();
993        let json = serde_json::to_string(&metadata).unwrap();
994        let deserialized: Metadata = serde_json::from_str(&json).unwrap();
995        assert_eq!(deserialized, metadata);
996    }
997
998    #[test]
999    fn serde_roundtrip_all_fields() {
1000        let metadata = Metadata {
1001            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1002            time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
1003            time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_003_600)),
1004            content_type: "application/json".into(),
1005            compression: Some(Compression::Zstd),
1006            origin: Some("10.0.0.1".into()),
1007            filename: Some("data.json".into()),
1008            size: Some(1024),
1009            custom: BTreeMap::from([("key".into(), "value".into())]),
1010        };
1011
1012        let json = serde_json::to_string(&metadata).unwrap();
1013        let deserialized: Metadata = serde_json::from_str(&json).unwrap();
1014        assert_eq!(deserialized, metadata);
1015    }
1016
1017    #[test]
1018    fn size_roundtrips_through_headers() {
1019        let metadata = Metadata {
1020            size: Some(42),
1021            ..Default::default()
1022        };
1023
1024        let headers = metadata.to_headers("").unwrap();
1025        assert_eq!(headers.get(HEADER_SIZE).unwrap(), "42");
1026        assert_eq!(Metadata::from_headers(&headers, "").unwrap().size, Some(42));
1027    }
1028
1029    #[test]
1030    fn size_is_prefixed_in_headers() {
1031        let metadata = Metadata {
1032            size: Some(42),
1033            ..Default::default()
1034        };
1035
1036        let headers = metadata.to_headers("x-goog-meta-").unwrap();
1037        assert_eq!(headers.get("x-goog-meta-x-sn-size").unwrap(), "42");
1038    }
1039
1040    #[test]
1041    fn from_insert_headers_ignores_size() {
1042        // Size is materialized by the server; a client-supplied value must never be trusted.
1043        let mut headers = HeaderMap::new();
1044        headers.insert(HEADER_SIZE, "9999".parse().unwrap());
1045
1046        let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
1047        assert!(metadata.size.is_none());
1048    }
1049
1050    #[test]
1051    fn from_headers_rejects_malformed_size() {
1052        let mut headers = HeaderMap::new();
1053        headers.insert(HEADER_SIZE, "not-a-number".parse().unwrap());
1054
1055        assert!(matches!(
1056            Metadata::from_headers(&headers, ""),
1057            Err(Error::Size(_))
1058        ));
1059    }
1060
1061    #[test]
1062    fn default_metadata() {
1063        let metadata = Metadata::default();
1064        assert_eq!(metadata.content_type, DEFAULT_CONTENT_TYPE);
1065        assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
1066        assert!(metadata.compression.is_none());
1067        assert!(metadata.origin.is_none());
1068        assert!(metadata.filename.is_none());
1069        assert!(metadata.time_created.is_none());
1070        assert!(metadata.time_expires.is_none());
1071        assert!(metadata.size.is_none());
1072        assert!(metadata.custom.is_empty());
1073    }
1074
1075    #[test]
1076    fn expiration_display_roundtrip() {
1077        let cases = [
1078            ExpirationPolicy::Manual,
1079            ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
1080            ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1081        ];
1082
1083        for policy in cases {
1084            let displayed = policy.to_string();
1085            let parsed: ExpirationPolicy = displayed.parse().unwrap();
1086            assert_eq!(parsed, policy);
1087        }
1088    }
1089
1090    #[test]
1091    fn expiration_parse_invalid() {
1092        assert!(ExpirationPolicy::from_str("garbage").is_err());
1093        assert!(ExpirationPolicy::from_str("ttl:").is_err());
1094        assert!(ExpirationPolicy::from_str("").is_err());
1095    }
1096
1097    #[test]
1098    fn expiration_policy_helpers() {
1099        assert_eq!(ExpirationPolicy::Manual.expires_in(), None);
1100        assert!(ExpirationPolicy::Manual.is_manual());
1101        assert!(!ExpirationPolicy::Manual.is_timeout());
1102
1103        let ttl = ExpirationPolicy::TimeToLive(Duration::from_mins(1));
1104        assert_eq!(ttl.expires_in(), Some(Duration::from_mins(1)));
1105        assert!(ttl.is_timeout());
1106        assert!(!ttl.is_manual());
1107
1108        let tti = ExpirationPolicy::TimeToIdle(Duration::from_mins(2));
1109        assert_eq!(tti.expires_in(), Some(Duration::from_mins(2)));
1110        assert!(tti.is_timeout());
1111        assert!(!tti.is_manual());
1112    }
1113
1114    #[test]
1115    fn compression_display_roundtrip() {
1116        let displayed = Compression::Zstd.to_string();
1117        assert_eq!(displayed, "zstd");
1118        let parsed: Compression = displayed.parse().unwrap();
1119        assert_eq!(parsed, Compression::Zstd);
1120    }
1121
1122    #[test]
1123    fn compression_parse_invalid() {
1124        assert!(Compression::from_str("gzip").is_err());
1125        assert!(Compression::from_str("").is_err());
1126    }
1127
1128    #[test]
1129    fn check_tti_bump_returns_none_for_manual() {
1130        let metadata = Metadata::default();
1131        assert!(metadata.check_tti_bump(SystemTime::now()).is_none());
1132    }
1133
1134    #[test]
1135    fn check_tti_bump_returns_none_for_ttl() {
1136        let now = SystemTime::now();
1137        let metadata = Metadata {
1138            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1139            time_expires: Some(now + Duration::from_hours(1)),
1140            ..Default::default()
1141        };
1142        assert!(metadata.check_tti_bump(now).is_none());
1143    }
1144
1145    #[test]
1146    fn check_tti_bump_returns_none_when_fresh() {
1147        let now = SystemTime::now();
1148        let tti = Duration::from_hours(2 * 24);
1149        let metadata = Metadata {
1150            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1151            time_expires: Some(now + tti),
1152            ..Default::default()
1153        };
1154        assert!(metadata.check_tti_bump(now).is_none());
1155    }
1156
1157    #[test]
1158    fn check_tti_bump_returns_new_deadline_when_stale() {
1159        let now = SystemTime::now();
1160        let tti = Duration::from_hours(2 * 24);
1161        let debounce = tti / 4;
1162        let stale_deadline = now + tti - debounce - Duration::from_mins(1);
1163        let metadata = Metadata {
1164            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1165            time_expires: Some(stale_deadline),
1166            ..Default::default()
1167        };
1168        let new_deadline = metadata.check_tti_bump(now).unwrap();
1169        assert_eq!(new_deadline, now + tti);
1170    }
1171
1172    #[test]
1173    fn check_tti_bump_short_tti_triggers_bump() {
1174        let now = SystemTime::now();
1175        let tti = Duration::from_hours(2);
1176        let debounce = tti / 4;
1177        let stale_deadline = now + tti - debounce - Duration::from_mins(1);
1178        let metadata = Metadata {
1179            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1180            time_expires: Some(stale_deadline),
1181            ..Default::default()
1182        };
1183        let new_deadline = metadata.check_tti_bump(now).unwrap();
1184        assert_eq!(new_deadline, now + tti);
1185    }
1186
1187    #[test]
1188    fn check_tti_bump_debounce_caps_at_24h() {
1189        let now = SystemTime::now();
1190        let tti = Duration::from_hours(30 * 24);
1191        let capped_debounce = Duration::from_hours(24);
1192        let stale_deadline = now + tti - capped_debounce - Duration::from_mins(1);
1193        let metadata = Metadata {
1194            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1195            time_expires: Some(stale_deadline),
1196            ..Default::default()
1197        };
1198        assert!(metadata.check_tti_bump(now).is_some());
1199
1200        let fresh_deadline = now + tti - capped_debounce + Duration::from_mins(1);
1201        let metadata = Metadata {
1202            time_expires: Some(fresh_deadline),
1203            ..metadata
1204        };
1205        assert!(metadata.check_tti_bump(now).is_none());
1206    }
1207
1208    #[test]
1209    fn check_tti_bump_returns_none_when_time_expires_missing() {
1210        let metadata = Metadata {
1211            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1212            time_expires: None,
1213            ..Default::default()
1214        };
1215        assert!(metadata.check_tti_bump(SystemTime::now()).is_none());
1216    }
1217}