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