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. Currently only
287/// Zstandard (`zstd`) is supported.
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    // /// Compressed using `lz4`.
295    // Lz4,
296}
297
298impl Compression {
299    /// Returns a string representation of the compression algorithm.
300    pub fn as_str(&self) -> &str {
301        match self {
302            Compression::Zstd => "zstd",
303            // Compression::Gzip => "gzip",
304            // Compression::Lz4 => "lz4",
305        }
306    }
307}
308
309impl fmt::Display for Compression {
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        f.write_str(self.as_str())
312    }
313}
314
315impl FromStr for Compression {
316    type Err = Error;
317
318    fn from_str(s: &str) -> Result<Self, Self::Err> {
319        match s {
320            "zstd" => Ok(Compression::Zstd),
321            // "gzip" => Compression::Gzip,
322            // "lz4" => Compression::Lz4,
323            _ => Err(Error::Compression),
324        }
325    }
326}
327
328/// Per-object metadata.
329///
330/// Includes first-class fields (expiration, compression, timestamps, etc.) and
331/// arbitrary user-provided key-value metadata. See the [module-level
332/// documentation](self) for the HTTP header mapping conventions.
333#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(default)]
335pub struct Metadata {
336    /// The expiration policy of the object (header: `x-sn-expiration`).
337    ///
338    /// Skipped during serialization when set to [`ExpirationPolicy::Manual`].
339    #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
340    pub expiration_policy: ExpirationPolicy,
341
342    /// The creation/last replacement time of the object (header: `x-sn-time-created`).
343    ///
344    /// Set by the server every time an object is put, i.e. when objects are first
345    /// created and when existing objects are overwritten.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub time_created: Option<Timestamp>,
348
349    /// The resolved expiration timestamp (header: `x-sn-time-expires`).
350    ///
351    /// Initially derived from the [`expiration_policy`](Self::expiration_policy), but may be later
352    /// extended by explicit updates. This timestamp reflects the deadline present *prior to* the
353    /// current access to the object.
354    ///
355    /// Fractional deadlines round up to whole seconds; see [`Timestamp`].
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub time_expires: Option<Timestamp>,
358
359    /// IANA media type of the object (header: `Content-Type`).
360    ///
361    /// Defaults to [`DEFAULT_CONTENT_TYPE`] (`application/octet-stream`).
362    pub content_type: Cow<'static, str>,
363
364    /// The compression algorithm used for this object (header: `Content-Encoding`).
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub compression: Option<Compression>,
367
368    /// The origin of the object (header: `x-sn-origin`).
369    ///
370    /// Typically the IP address of the original source. This is an optional but
371    /// encouraged field that tracks where the payload was originally obtained
372    /// from (e.g. the IP of a Sentry SDK or CLI).
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub origin: Option<String>,
375
376    /// An optional filename associated with this object (header: `x-sn-filename`).
377    ///
378    /// When present, the server includes a `Content-Disposition: attachment; filename="<filename>"`
379    /// header in GET responses, prompting browsers and download tools to save the file
380    /// under this name. Non-ASCII filenames additionally get an RFC 8187 `filename*` parameter.
381    ///
382    /// This is a logical string and may contain arbitrary Unicode; it is escaped only on the
383    /// wire (see [the module docs](self#escaping-free-form-values)).
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub filename: Option<String>,
386
387    /// Size of the stored data in bytes, if known (header: `x-sn-size`).
388    ///
389    /// Read-only. This is the size of the complete object, even when only a range of it is
390    /// being returned. It describes the stored bytes, so for a compressed object it is the
391    /// compressed size.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub size: Option<usize>,
394
395    /// Arbitrary user-provided key-value metadata (header prefix: `x-snme-`).
396    ///
397    /// Each entry is transmitted as `x-snme-{key}: {value}`.
398    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
399    pub custom: BTreeMap<String, String>,
400}
401
402impl Metadata {
403    /// Parses the metadata headers accepted from writing endpoints.
404    ///
405    /// Unlike [`from_headers`](Self::from_headers), this skips parsing read-only attributes so
406    /// clients cannot set them via headers.
407    ///
408    /// Uses `access_time` to materialize the following attributes:
409    /// - [`time_created`](Self::time_created)
410    /// - [`time_expires`](Self::time_expires)
411    ///
412    /// A prefix can also be provided which is stripped from custom non-standard headers.
413    pub fn from_insert_headers(
414        headers: &HeaderMap,
415        prefix: &str,
416        access_time: Timestamp,
417    ) -> Result<Self, Error> {
418        let mut metadata = Self::parse_headers(headers, prefix, true)?;
419
420        metadata.time_created = Some(access_time);
421        metadata.time_expires = metadata
422            .expiration_policy
423            .expires_in()
424            .map(|ttl| access_time.checked_add(ttl).ok_or(InvalidTimestamp))
425            .transpose()?;
426
427        Ok(metadata)
428    }
429
430    /// Validates internal consistency of the metadata.
431    ///
432    /// A time-based [`expiration_policy`](Self::expiration_policy) must carry a resolved
433    /// [`time_expires`](Self::time_expires); backends rely on this to persist a concrete
434    /// expiration.
435    pub fn validate(&self) -> Result<(), Error> {
436        if self.expiration_policy.is_timeout() && self.time_expires.is_none() {
437            return Err(Error::Invariant(
438                "expiration policy requires a resolved expiration time",
439            ));
440        }
441        if self.expiration_policy.is_manual() && self.time_expires.is_some() {
442            return Err(Error::Invariant(
443                "manual expiration policy must not have a resolved expiration time",
444            ));
445        }
446        Ok(())
447    }
448
449    /// Returns whether the object has expired at the given time.
450    pub fn is_expired(&self, access_time: Timestamp) -> bool {
451        self.time_expires
452            .is_some_and(|deadline| deadline < access_time)
453    }
454
455    /// Checks whether this object's TTI deadline needs bumping.
456    ///
457    /// See [`ExpirationPolicy::check_tti_bump`] for details.
458    pub fn check_tti_bump(&self, access_time: Timestamp) -> Option<Timestamp> {
459        self.expiration_policy
460            .check_tti_bump(self.time_expires, access_time)
461    }
462
463    /// Extracts public API metadata from the given [`HeaderMap`].
464    ///
465    /// A prefix can be also be provided which is being stripped from custom non-standard headers.
466    pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
467        Self::parse_headers(headers, prefix, false)
468    }
469
470    /// Parses metadata from the given [`HeaderMap`].
471    ///
472    /// When `skip_read_only` is set, read-only attributes are not parsed off the headers, so a
473    /// malformed client-supplied value cannot fail the parse. A prefix can also be provided which
474    /// is stripped from custom non-standard headers.
475    fn parse_headers(
476        headers: &HeaderMap,
477        prefix: &str,
478        skip_read_only: bool,
479    ) -> Result<Self, Error> {
480        let mut metadata = Metadata::default();
481
482        for (name, value) in headers {
483            match *name {
484                // standard HTTP headers
485                header::CONTENT_TYPE => {
486                    let content_type = value.to_str()?;
487                    validate_content_type(content_type)?;
488                    metadata.content_type = content_type.to_owned().into();
489                }
490                header::CONTENT_ENCODING => {
491                    let compression = value.to_str()?;
492                    metadata.compression = Some(Compression::from_str(compression)?);
493                }
494                _ => {
495                    let Some(name) = name.as_str().strip_prefix(prefix) else {
496                        continue;
497                    };
498
499                    match name {
500                        // Objectstore first-class metadata
501                        HEADER_EXPIRATION => {
502                            let expiration_policy = value.to_str()?;
503                            metadata.expiration_policy =
504                                ExpirationPolicy::from_str(expiration_policy)?;
505                        }
506                        HEADER_TIME_CREATED if !skip_read_only => {
507                            let timestamp = value.to_str()?;
508                            let time = Timestamp::from_rfc3339(timestamp)?;
509                            metadata.time_created = Some(time);
510                        }
511                        HEADER_TIME_EXPIRES if !skip_read_only => {
512                            let timestamp = value.to_str()?;
513                            let time = Timestamp::from_rfc3339(timestamp)?;
514                            metadata.time_expires = Some(time);
515                        }
516                        HEADER_ORIGIN => {
517                            metadata.origin = Some(headers::decode_header_value(value)?);
518                        }
519                        HEADER_FILENAME => {
520                            metadata.filename = Some(headers::decode_header_value(value)?);
521                        }
522                        HEADER_SIZE if !skip_read_only => {
523                            let size = value.to_str()?;
524                            metadata.size = Some(size.parse()?);
525                        }
526                        _ => {
527                            // customer-provided metadata
528                            if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
529                                let value = headers::decode_header_value(value)?;
530                                metadata.custom.insert(name.into(), value);
531                            }
532                        }
533                    }
534                }
535            }
536        }
537
538        Ok(metadata)
539    }
540
541    /// Turns the metadata into a [`HeaderMap`] for the public API.
542    ///
543    /// It will prefix any non-standard headers with the given `prefix`. GCS-specific headers are
544    /// not emitted; backends handle those separately.
545    pub fn to_headers(&self, prefix: &str) -> Result<HeaderMap, Error> {
546        let Self {
547            content_type,
548            compression,
549            origin,
550            filename,
551            expiration_policy,
552            time_created,
553            time_expires,
554            size,
555            custom,
556        } = self;
557
558        let mut headers = HeaderMap::new();
559
560        // standard headers
561        headers.append(header::CONTENT_TYPE, content_type.parse()?);
562        if let Some(compression) = compression {
563            headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
564        }
565
566        // Objectstore first-class metadata
567        if *expiration_policy != ExpirationPolicy::Manual {
568            let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
569            headers.append(name, expiration_policy.to_string().parse()?);
570        }
571        if let Some(time) = time_created {
572            let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
573            let timestamp = time.as_rfc3339();
574            headers.append(name, timestamp.to_string().parse()?);
575        }
576        if let Some(time) = time_expires {
577            let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_EXPIRES}"))?;
578            let timestamp = time.as_rfc3339();
579            headers.append(name, timestamp.to_string().parse()?);
580        }
581        if let Some(origin) = origin {
582            let name = HeaderName::try_from(format!("{prefix}{HEADER_ORIGIN}"))?;
583            headers.append(name, headers::encode_header_value(origin));
584        }
585        if let Some(filename) = filename {
586            let name = HeaderName::try_from(format!("{prefix}{HEADER_FILENAME}"))?;
587            headers.append(name, headers::encode_header_value(filename));
588        }
589        if let Some(size) = size {
590            let name = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
591            headers.append(name, size.to_string().parse()?);
592        }
593
594        // customer-provided metadata
595        for (key, value) in custom {
596            let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
597            headers.append(name, headers::encode_header_value(value));
598        }
599
600        Ok(headers)
601    }
602}
603
604/// Validates that `content_type` is a valid [IANA Media
605/// Type](https://www.iana.org/assignments/media-types/media-types.xhtml).
606fn validate_content_type(content_type: &str) -> Result<(), Error> {
607    mediatype::MediaType::parse(content_type)?;
608    Ok(())
609}
610
611impl Default for Metadata {
612    fn default() -> Self {
613        Self {
614            expiration_policy: ExpirationPolicy::Manual,
615            time_created: None,
616            time_expires: None,
617            content_type: DEFAULT_CONTENT_TYPE.into(),
618            compression: None,
619            origin: None,
620            filename: None,
621            size: None,
622            custom: BTreeMap::new(),
623        }
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn metadata_update_parses_supported_expiry_extensions() {
633        let cases = [
634            (
635                r#"{"extend_expiry":{"at":"2026-10-16T12:00:00Z"}}"#,
636                ExpiryExtension::At {
637                    at: "2026-10-16T12:00:00Z".parse().unwrap(),
638                },
639            ),
640            (
641                r#"{"extend_expiry":{"after":"30d","from":"creation"}}"#,
642                ExpiryExtension::After {
643                    after: Duration::from_secs(30 * 86400),
644                    from: ExpiryAnchor::Creation,
645                },
646            ),
647            (
648                r#"{"extend_expiry":{"after":"0s","from":"now"}}"#,
649                ExpiryExtension::After {
650                    after: Duration::ZERO,
651                    from: ExpiryAnchor::Now,
652                },
653            ),
654        ];
655
656        for (json, expected) in cases {
657            let update: MetadataUpdate = serde_json::from_str(json).unwrap();
658            assert_eq!(update.extend_expiry, expected);
659            assert_eq!(serde_json::to_string(&update).unwrap(), json);
660        }
661    }
662
663    #[test]
664    fn metadata_update_rejects_invalid_structures() {
665        let cases = [
666            r#"{"extend_expiry":{"at":"not a timestamp"}}"#,
667            r#"{"extend_expiry":{"after":"not a duration","from":"now"}}"#,
668            r#"{}"#,
669            r#"{"extend_expiry":null}"#,
670            r#"{"extend_expiry":{}}"#,
671            r#"{"extend_expiry":{"at":"2026-10-16T12:00:00Z","after":"30d","from":"now"}}"#,
672            r#"{"extend_expiry":{"after":"30d"}}"#,
673            r#"{"extend_expiry":{"after":"30d","from":"unsupported"}}"#,
674            r#"{"extend_expiry":{"at":"2026-10-16T12:00:00Z","unknown":true}}"#,
675            r#"{"extend_expiry":{"after":"30d","from":"now","unknown":true}}"#,
676            r#"{"extend_expiry":{"at":"2026-10-16T12:00:00Z"},"content_type":"text/plain"}"#,
677        ];
678
679        for json in cases {
680            assert!(
681                serde_json::from_str::<MetadataUpdate>(json).is_err(),
682                "unexpectedly accepted {json}"
683            );
684        }
685    }
686
687    #[test]
688    fn from_headers_with_origin() {
689        let mut headers = HeaderMap::new();
690        headers.insert("content-type", "text/plain".parse().unwrap());
691        headers.insert(HEADER_ORIGIN, "203.0.113.42".parse().unwrap());
692
693        let metadata = Metadata::from_headers(&headers, "").unwrap();
694        assert_eq!(metadata.origin.as_deref(), Some("203.0.113.42"));
695        assert_eq!(metadata.content_type, "text/plain");
696    }
697
698    #[test]
699    fn from_headers_without_origin() {
700        let mut headers = HeaderMap::new();
701        headers.insert("content-type", "text/plain".parse().unwrap());
702
703        let metadata = Metadata::from_headers(&headers, "").unwrap();
704        assert!(metadata.origin.is_none());
705    }
706
707    #[test]
708    fn to_headers_with_origin() {
709        let metadata = Metadata {
710            origin: Some("203.0.113.42".into()),
711            ..Default::default()
712        };
713
714        let headers = metadata.to_headers("").unwrap();
715        assert_eq!(headers.get(HEADER_ORIGIN).unwrap(), "203.0.113.42");
716    }
717
718    #[test]
719    fn to_headers_without_origin() {
720        let metadata = Metadata::default();
721        let headers = metadata.to_headers("").unwrap();
722        assert!(headers.get(HEADER_ORIGIN).is_none());
723    }
724
725    #[test]
726    fn origin_header_roundtrip() {
727        let metadata = Metadata {
728            origin: Some("203.0.113.42".into()),
729            ..Default::default()
730        };
731
732        let headers = metadata.to_headers("").unwrap();
733        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
734        assert_eq!(roundtripped.origin, metadata.origin);
735    }
736
737    #[test]
738    fn from_headers_with_filename() {
739        let mut headers = HeaderMap::new();
740        headers.insert(HEADER_FILENAME, "report.pdf".parse().unwrap());
741
742        let metadata = Metadata::from_headers(&headers, "").unwrap();
743        assert_eq!(metadata.filename.as_deref(), Some("report.pdf"));
744    }
745
746    #[test]
747    fn from_headers_without_filename() {
748        let headers = HeaderMap::new();
749        let metadata = Metadata::from_headers(&headers, "").unwrap();
750        assert!(metadata.filename.is_none());
751    }
752
753    #[test]
754    fn to_headers_with_filename() {
755        let metadata = Metadata {
756            filename: Some("report.pdf".into()),
757            ..Default::default()
758        };
759
760        let headers = metadata.to_headers("").unwrap();
761        assert_eq!(headers.get(HEADER_FILENAME).unwrap(), "report.pdf");
762    }
763
764    #[test]
765    fn to_headers_without_filename() {
766        let metadata = Metadata::default();
767        let headers = metadata.to_headers("").unwrap();
768        assert!(headers.get(HEADER_FILENAME).is_none());
769    }
770
771    #[test]
772    fn filename_header_roundtrip() {
773        let metadata = Metadata {
774            filename: Some("report.pdf".into()),
775            ..Default::default()
776        };
777
778        let headers = metadata.to_headers("").unwrap();
779        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
780        assert_eq!(roundtripped.filename, metadata.filename);
781    }
782
783    /// Every free-form field is escaped on the way out and decoded on the way back in.
784    ///
785    /// The escaping itself is covered in [`crate::headers`]; this only pins down that each of the
786    /// three fields that needs it actually goes through it, in both directions.
787    #[test]
788    fn free_form_values_are_escaped_on_the_wire() {
789        let metadata = Metadata {
790            origin: Some("Ünknown-源".into()),
791            filename: Some("réport-📄.pdf".into()),
792            custom: BTreeMap::from([("release".to_owned(), "100% vérsion-🚀".to_owned())]),
793            ..Default::default()
794        };
795
796        let headers = metadata.to_headers("").unwrap();
797        assert_eq!(
798            headers.get(HEADER_ORIGIN).unwrap(),
799            "%C3%9Cnknown-%E6%BA%90"
800        );
801        assert_eq!(
802            headers.get(HEADER_FILENAME).unwrap(),
803            "r%C3%A9port-%F0%9F%93%84.pdf",
804        );
805        assert_eq!(
806            headers.get(format!("{HEADER_META_PREFIX}release")).unwrap(),
807            "100%25 v%C3%A9rsion-%F0%9F%9A%80",
808        );
809
810        let roundtripped = Metadata::from_headers(&headers, "").unwrap();
811        assert_eq!(roundtripped.origin, metadata.origin);
812        assert_eq!(roundtripped.filename, metadata.filename);
813        assert_eq!(roundtripped.custom, metadata.custom);
814    }
815
816    #[test]
817    fn from_headers_content_type_and_encoding() {
818        let mut headers = HeaderMap::new();
819        headers.insert("content-type", "application/json".parse().unwrap());
820        headers.insert("content-encoding", "zstd".parse().unwrap());
821
822        let metadata = Metadata::from_headers(&headers, "").unwrap();
823        assert_eq!(metadata.content_type, "application/json");
824        assert_eq!(metadata.compression, Some(Compression::Zstd));
825    }
826
827    #[test]
828    fn from_headers_expiration_policy() {
829        let mut headers = HeaderMap::new();
830        headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
831
832        let metadata = Metadata::from_headers(&headers, "").unwrap();
833        assert_eq!(
834            metadata.expiration_policy,
835            ExpirationPolicy::TimeToLive(Duration::from_secs(30))
836        );
837    }
838
839    #[test]
840    fn expiration_policy_keeps_long_durations_in_days() {
841        let ttl = Duration::from_secs(400 * 86400 + 3600);
842        let policy = ExpirationPolicy::TimeToLive(ttl);
843
844        assert_eq!(policy.to_string(), "ttl:400d 1h");
845        assert_eq!(
846            policy.to_string().parse::<ExpirationPolicy>().unwrap(),
847            policy
848        );
849    }
850
851    #[test]
852    fn expiration_policy_parses_units_that_are_never_emitted() {
853        let policy: ExpirationPolicy = "tti:2weeks".parse().unwrap();
854        assert_eq!(
855            policy,
856            ExpirationPolicy::TimeToIdle(Duration::from_secs(14 * 86400))
857        );
858        // Re-emitting normalizes to the units of the wire format.
859        assert_eq!(policy.to_string(), "tti:14d");
860    }
861
862    #[test]
863    fn from_headers_timestamps() {
864        let mut headers = HeaderMap::new();
865        headers.insert(
866            HEADER_TIME_CREATED,
867            "2024-01-15T12:00:00.123456Z".parse().unwrap(),
868        );
869        headers.insert(
870            HEADER_TIME_EXPIRES,
871            "2024-01-16T12:00:00.123456Z".parse().unwrap(),
872        );
873
874        let metadata = Metadata::from_headers(&headers, "").unwrap();
875        let encoded = metadata.to_headers("").unwrap();
876        assert_eq!(encoded[HEADER_TIME_CREATED], "2024-01-15T12:00:01Z");
877        assert_eq!(encoded[HEADER_TIME_EXPIRES], "2024-01-16T12:00:01Z");
878        let deadline = metadata.time_expires.unwrap();
879        assert!(!metadata.is_expired(deadline - Duration::from_secs(1)));
880        assert!(!metadata.is_expired(deadline));
881        assert!(metadata.is_expired(deadline + Duration::from_secs(1)));
882    }
883
884    #[test]
885    fn from_insert_headers_ignores_read_only_fields() {
886        // Read-only and output attributes must never be taken from an untrusted
887        // client request, even if the client supplies the headers.
888        let forged_created = "2024-01-15T12:00:00.000000Z";
889        let mut headers = HeaderMap::new();
890        headers.insert("content-type", "text/plain".parse().unwrap());
891        headers.insert(HEADER_TIME_CREATED, forged_created.parse().unwrap());
892        headers.insert(
893            HEADER_TIME_EXPIRES,
894            "2024-01-16T12:00:00.000000Z".parse().unwrap(),
895        );
896
897        let metadata = Metadata::from_insert_headers(&headers, "", Timestamp::now()).unwrap();
898        // `time_created` is stamped by the server, not the client's forged value.
899        let created = metadata.time_created.unwrap();
900        assert_ne!(created, Timestamp::from_rfc3339(forged_created).unwrap());
901        assert!(metadata.time_expires.is_none());
902        assert!(metadata.size.is_none());
903        // Client-settable fields are still parsed.
904        assert_eq!(metadata.content_type, "text/plain");
905    }
906
907    #[test]
908    fn from_insert_headers_ignores_malformed_read_only_fields() {
909        // A malformed read-only header must not fail the write: it is skipped, not parsed.
910        let mut headers = HeaderMap::new();
911        headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
912        headers.insert(HEADER_TIME_EXPIRES, "not-a-timestamp".parse().unwrap());
913
914        let metadata = Metadata::from_insert_headers(&headers, "", Timestamp::now()).unwrap();
915        assert!(metadata.time_created.is_some());
916        assert!(metadata.time_expires.is_none());
917    }
918
919    #[test]
920    fn from_insert_headers_resolves_time_expires_for_ttl() {
921        let mut headers = HeaderMap::new();
922        headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
923
924        let access_time = Timestamp::UNIX_EPOCH;
925        let metadata = Metadata::from_insert_headers(&headers, "", access_time).unwrap();
926        let created = metadata.time_created.unwrap();
927        assert_eq!(created, access_time);
928        let expires = metadata.time_expires.unwrap();
929        assert_eq!(expires, created + Duration::from_secs(30));
930    }
931
932    #[test]
933    fn from_insert_headers_resolves_time_expires_for_tti() {
934        let mut headers = HeaderMap::new();
935        headers.insert(HEADER_EXPIRATION, "tti:1h".parse().unwrap());
936
937        let access_time = Timestamp::UNIX_EPOCH;
938        let metadata = Metadata::from_insert_headers(&headers, "", access_time).unwrap();
939        let created = metadata.time_created.unwrap();
940        assert_eq!(created, access_time);
941        let expires = metadata.time_expires.unwrap();
942        assert_eq!(expires, created + Duration::from_hours(1));
943    }
944
945    #[test]
946    fn from_insert_headers_manual_leaves_time_expires_none() {
947        let headers = HeaderMap::new();
948        let metadata = Metadata::from_insert_headers(&headers, "", Timestamp::now()).unwrap();
949        assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
950        assert!(metadata.time_expires.is_none());
951    }
952
953    #[test]
954    fn validate_accepts_resolved_timeout() {
955        let metadata = Metadata {
956            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
957            time_expires: Some(Timestamp::now() + Duration::from_secs(30)),
958            ..Default::default()
959        };
960        assert!(metadata.validate().is_ok());
961    }
962
963    #[test]
964    fn validate_accepts_manual_without_expiry() {
965        let metadata = Metadata::default();
966        assert!(metadata.validate().is_ok());
967    }
968
969    #[test]
970    fn validate_rejects_timeout_without_expiry() {
971        let metadata = Metadata {
972            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
973            time_expires: None,
974            ..Default::default()
975        };
976        assert!(matches!(metadata.validate(), Err(Error::Invariant(_))));
977    }
978
979    #[test]
980    fn from_headers_custom_metadata_with_prefix() {
981        let mut headers = HeaderMap::new();
982        // Simulate a backend that prefixes headers, e.g. "x-goog-meta-"
983        let prefix = "x-goog-meta-";
984        let expiration_header: HeaderName = format!("{prefix}{HEADER_EXPIRATION}").parse().unwrap();
985        headers.insert(expiration_header, "tti:1h".parse().unwrap());
986
987        let custom_header: HeaderName = format!("{prefix}{HEADER_META_PREFIX}my-key")
988            .parse()
989            .unwrap();
990        headers.insert(custom_header, "my-value".parse().unwrap());
991
992        let metadata = Metadata::from_headers(&headers, prefix).unwrap();
993        assert_eq!(
994            metadata.expiration_policy,
995            ExpirationPolicy::TimeToIdle(Duration::from_hours(1))
996        );
997        assert_eq!(metadata.custom.get("my-key").unwrap(), "my-value");
998    }
999
1000    #[test]
1001    fn from_headers_invalid_content_type() {
1002        let mut headers = HeaderMap::new();
1003        headers.insert("content-type", "not a valid media type!".parse().unwrap());
1004
1005        let err = Metadata::from_headers(&headers, "").unwrap_err();
1006        assert!(matches!(err, Error::ContentType(_)));
1007    }
1008
1009    #[test]
1010    fn from_headers_invalid_compression() {
1011        let mut headers = HeaderMap::new();
1012        headers.insert("content-encoding", "brotli".parse().unwrap());
1013
1014        let err = Metadata::from_headers(&headers, "").unwrap_err();
1015        assert!(matches!(err, Error::Compression));
1016    }
1017
1018    #[test]
1019    fn from_headers_invalid_expiration() {
1020        let mut headers = HeaderMap::new();
1021        headers.insert(HEADER_EXPIRATION, "garbage".parse().unwrap());
1022
1023        let err = Metadata::from_headers(&headers, "").unwrap_err();
1024        assert!(matches!(err, Error::Expiration(_)));
1025    }
1026
1027    #[test]
1028    fn from_headers_invalid_timestamp() {
1029        let mut headers = HeaderMap::new();
1030        headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
1031
1032        let err = Metadata::from_headers(&headers, "").unwrap_err();
1033        assert!(matches!(err, Error::CreationTime(_)));
1034    }
1035
1036    #[test]
1037    fn to_headers_all_fields() {
1038        let metadata = Metadata {
1039            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_mins(1)),
1040            time_created: Some(Timestamp::from_unix_secs(1_700_000_000).unwrap()),
1041            time_expires: Some(Timestamp::from_unix_secs(1_700_000_060).unwrap()),
1042            content_type: "text/html".into(),
1043            compression: Some(Compression::Zstd),
1044            origin: Some("10.0.0.1".into()),
1045            filename: Some("report.pdf".into()),
1046            size: None,
1047            custom: BTreeMap::from([("foo".into(), "bar".into())]),
1048        };
1049
1050        let headers = metadata.to_headers("pfx-").unwrap();
1051        let map: BTreeMap<_, _> = headers
1052            .iter()
1053            .map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
1054            .collect();
1055
1056        insta::assert_debug_snapshot!(map, @r#"
1057        {
1058            "content-encoding": "zstd",
1059            "content-type": "text/html",
1060            "pfx-x-sn-expiration": "ttl:1m",
1061            "pfx-x-sn-filename": "report.pdf",
1062            "pfx-x-sn-origin": "10.0.0.1",
1063            "pfx-x-sn-time-created": "2023-11-14T22:13:20Z",
1064            "pfx-x-sn-time-expires": "2023-11-14T22:14:20Z",
1065            "pfx-x-snme-foo": "bar",
1066        }
1067        "#);
1068    }
1069
1070    #[test]
1071    fn full_roundtrip_all_fields() {
1072        let prefix = "x-test-";
1073        let metadata = Metadata {
1074            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(2)),
1075            time_created: Some(Timestamp::from_unix_secs(1_700_000_000).unwrap()),
1076            time_expires: Some(Timestamp::from_unix_secs(1_700_007_200).unwrap()),
1077            content_type: "image/png".into(),
1078            compression: Some(Compression::Zstd),
1079            origin: Some("192.168.1.1".into()),
1080            filename: Some("image.png".into()),
1081            size: None,
1082            custom: BTreeMap::from([
1083                ("key1".into(), "value1".into()),
1084                ("key2".into(), "value2".into()),
1085            ]),
1086        };
1087
1088        let headers = metadata.to_headers(prefix).unwrap();
1089        let roundtripped = Metadata::from_headers(&headers, prefix).unwrap();
1090
1091        assert_eq!(roundtripped.expiration_policy, metadata.expiration_policy);
1092        assert_eq!(roundtripped.content_type, metadata.content_type);
1093        assert_eq!(roundtripped.compression, metadata.compression);
1094        assert_eq!(roundtripped.origin, metadata.origin);
1095        assert_eq!(roundtripped.filename, metadata.filename);
1096        assert_eq!(roundtripped.time_created, metadata.time_created);
1097        assert_eq!(roundtripped.time_expires, metadata.time_expires);
1098        assert_eq!(roundtripped.custom, metadata.custom);
1099    }
1100
1101    #[test]
1102    fn from_headers_empty() {
1103        let headers = HeaderMap::new();
1104        let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
1105        assert_eq!(metadata, Metadata::default());
1106    }
1107
1108    #[test]
1109    fn from_headers_invalid_time_expires() {
1110        let mut headers = HeaderMap::new();
1111        let name: HeaderName = format!("x-goog-meta-{HEADER_TIME_EXPIRES}")
1112            .parse()
1113            .unwrap();
1114        headers.insert(name, "not-a-timestamp".parse().unwrap());
1115
1116        // NOTE: This produces InvalidCreationTime even for time_expires because
1117        // both fields share the same humantime::TimestampError #[from] conversion.
1118        assert!(Metadata::from_headers(&headers, "x-goog-meta-").is_err());
1119    }
1120
1121    #[test]
1122    fn serde_roundtrip_default() {
1123        let metadata = Metadata::default();
1124        let json = serde_json::to_string(&metadata).unwrap();
1125        let deserialized: Metadata = serde_json::from_str(&json).unwrap();
1126        assert_eq!(deserialized, metadata);
1127    }
1128
1129    #[test]
1130    fn serde_roundtrip_all_fields() {
1131        let metadata = Metadata {
1132            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1133            time_created: Some(Timestamp::from_unix_secs(1_700_000_000).unwrap()),
1134            time_expires: Some(Timestamp::from_unix_secs(1_700_003_600).unwrap()),
1135            content_type: "application/json".into(),
1136            compression: Some(Compression::Zstd),
1137            origin: Some("10.0.0.1".into()),
1138            filename: Some("data.json".into()),
1139            size: Some(1024),
1140            custom: BTreeMap::from([("key".into(), "value".into())]),
1141        };
1142
1143        let json = serde_json::to_string(&metadata).unwrap();
1144        let deserialized: Metadata = serde_json::from_str(&json).unwrap();
1145        assert_eq!(deserialized, metadata);
1146    }
1147
1148    #[test]
1149    fn size_roundtrips_through_headers() {
1150        let metadata = Metadata {
1151            size: Some(42),
1152            ..Default::default()
1153        };
1154
1155        let headers = metadata.to_headers("").unwrap();
1156        assert_eq!(headers.get(HEADER_SIZE).unwrap(), "42");
1157        assert_eq!(Metadata::from_headers(&headers, "").unwrap().size, Some(42));
1158    }
1159
1160    #[test]
1161    fn size_is_prefixed_in_headers() {
1162        let metadata = Metadata {
1163            size: Some(42),
1164            ..Default::default()
1165        };
1166
1167        let headers = metadata.to_headers("x-goog-meta-").unwrap();
1168        assert_eq!(headers.get("x-goog-meta-x-sn-size").unwrap(), "42");
1169    }
1170
1171    #[test]
1172    fn from_insert_headers_ignores_size() {
1173        // Size is materialized by the server; a client-supplied value must never be trusted.
1174        let mut headers = HeaderMap::new();
1175        headers.insert(HEADER_SIZE, "9999".parse().unwrap());
1176
1177        let metadata = Metadata::from_insert_headers(&headers, "", Timestamp::now()).unwrap();
1178        assert!(metadata.size.is_none());
1179    }
1180
1181    #[test]
1182    fn from_headers_rejects_malformed_size() {
1183        let mut headers = HeaderMap::new();
1184        headers.insert(HEADER_SIZE, "not-a-number".parse().unwrap());
1185
1186        assert!(matches!(
1187            Metadata::from_headers(&headers, ""),
1188            Err(Error::Size(_))
1189        ));
1190    }
1191
1192    #[test]
1193    fn default_metadata() {
1194        let metadata = Metadata::default();
1195        assert_eq!(metadata.content_type, DEFAULT_CONTENT_TYPE);
1196        assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
1197        assert!(metadata.compression.is_none());
1198        assert!(metadata.origin.is_none());
1199        assert!(metadata.filename.is_none());
1200        assert!(metadata.time_created.is_none());
1201        assert!(metadata.time_expires.is_none());
1202        assert!(metadata.size.is_none());
1203        assert!(metadata.custom.is_empty());
1204    }
1205
1206    #[test]
1207    fn expiration_display_roundtrip() {
1208        let cases = [
1209            ExpirationPolicy::Manual,
1210            ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
1211            ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1212        ];
1213
1214        for policy in cases {
1215            let displayed = policy.to_string();
1216            let parsed: ExpirationPolicy = displayed.parse().unwrap();
1217            assert_eq!(parsed, policy);
1218        }
1219    }
1220
1221    #[test]
1222    fn expiration_parse_invalid() {
1223        assert!(ExpirationPolicy::from_str("garbage").is_err());
1224        assert!(ExpirationPolicy::from_str("ttl:").is_err());
1225        assert!(ExpirationPolicy::from_str("").is_err());
1226    }
1227
1228    #[test]
1229    fn expiration_policy_helpers() {
1230        assert_eq!(ExpirationPolicy::Manual.expires_in(), None);
1231        assert!(ExpirationPolicy::Manual.is_manual());
1232        assert!(!ExpirationPolicy::Manual.is_timeout());
1233
1234        let ttl = ExpirationPolicy::TimeToLive(Duration::from_mins(1));
1235        assert_eq!(ttl.expires_in(), Some(Duration::from_mins(1)));
1236        assert!(ttl.is_timeout());
1237        assert!(!ttl.is_manual());
1238
1239        let tti = ExpirationPolicy::TimeToIdle(Duration::from_mins(2));
1240        assert_eq!(tti.expires_in(), Some(Duration::from_mins(2)));
1241        assert!(tti.is_timeout());
1242        assert!(!tti.is_manual());
1243    }
1244
1245    #[test]
1246    fn compression_display_roundtrip() {
1247        let displayed = Compression::Zstd.to_string();
1248        assert_eq!(displayed, "zstd");
1249        let parsed: Compression = displayed.parse().unwrap();
1250        assert_eq!(parsed, Compression::Zstd);
1251    }
1252
1253    #[test]
1254    fn compression_parse_invalid() {
1255        assert!(Compression::from_str("gzip").is_err());
1256        assert!(Compression::from_str("").is_err());
1257    }
1258
1259    #[test]
1260    fn check_tti_bump_returns_none_for_manual() {
1261        let metadata = Metadata::default();
1262        assert!(metadata.check_tti_bump(Timestamp::now()).is_none());
1263    }
1264
1265    #[test]
1266    fn check_tti_bump_returns_none_for_ttl() {
1267        let now = Timestamp::now();
1268        let metadata = Metadata {
1269            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1270            time_expires: Some(now + Duration::from_hours(1)),
1271            ..Default::default()
1272        };
1273        assert!(metadata.check_tti_bump(now).is_none());
1274    }
1275
1276    #[test]
1277    fn check_tti_bump_returns_none_when_fresh() {
1278        let now = Timestamp::now();
1279        let tti = Duration::from_hours(2 * 24);
1280        let metadata = Metadata {
1281            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1282            time_expires: Some(now + tti),
1283            ..Default::default()
1284        };
1285        assert!(metadata.check_tti_bump(now).is_none());
1286    }
1287
1288    #[test]
1289    fn check_tti_bump_returns_new_deadline_when_stale() {
1290        let now = Timestamp::now();
1291        let tti = Duration::from_hours(2 * 24);
1292        let debounce = tti / 4;
1293        let stale_deadline = now + tti - debounce - Duration::from_mins(1);
1294        let metadata = Metadata {
1295            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1296            time_expires: Some(stale_deadline),
1297            ..Default::default()
1298        };
1299        let new_deadline = metadata.check_tti_bump(now).unwrap();
1300        assert_eq!(new_deadline, now + tti);
1301    }
1302
1303    #[test]
1304    fn check_tti_bump_short_tti_triggers_bump() {
1305        let now = Timestamp::now();
1306        for tti in [
1307            Duration::from_hours(2),
1308            Duration::from_secs(3),
1309            Duration::from_secs(4),
1310        ] {
1311            let debounce = tti / 4;
1312            let new_deadline = now + tti;
1313            let mut metadata = Metadata {
1314                expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1315                time_expires: Some(new_deadline - Duration::from_secs(debounce.as_secs() + 1)),
1316                ..Default::default()
1317            };
1318            assert_eq!(metadata.check_tti_bump(now), Some(new_deadline));
1319            metadata.time_expires = Some(new_deadline - Duration::from_secs(debounce.as_secs()));
1320            assert!(metadata.check_tti_bump(now).is_none());
1321        }
1322    }
1323
1324    #[test]
1325    fn check_tti_bump_debounce_caps_at_24h() {
1326        let now = Timestamp::now();
1327        let tti = Duration::from_hours(30 * 24);
1328        let capped_debounce = Duration::from_hours(24);
1329        let stale_deadline = now + tti - capped_debounce - Duration::from_mins(1);
1330        let metadata = Metadata {
1331            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1332            time_expires: Some(stale_deadline),
1333            ..Default::default()
1334        };
1335        assert!(metadata.check_tti_bump(now).is_some());
1336
1337        let fresh_deadline = now + tti - capped_debounce + Duration::from_mins(1);
1338        let metadata = Metadata {
1339            time_expires: Some(fresh_deadline),
1340            ..metadata
1341        };
1342        assert!(metadata.check_tti_bump(now).is_none());
1343    }
1344
1345    #[test]
1346    fn check_tti_bump_returns_none_when_time_expires_missing() {
1347        let metadata = Metadata {
1348            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1349            time_expires: None,
1350            ..Default::default()
1351        };
1352        assert!(metadata.check_tti_bump(Timestamp::now()).is_none());
1353    }
1354}