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