1use 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
63pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
65pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
67pub const HEADER_TIME_EXPIRES: &str = "x-sn-time-expires";
69pub const HEADER_ORIGIN: &str = "x-sn-origin";
71pub const HEADER_FILENAME: &str = "x-sn-filename";
73pub const HEADER_SIZE: &str = "x-sn-size";
75pub const HEADER_META_PREFIX: &str = "x-snme-";
77
78pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
80
81const MAX_TTI_DEBOUNCE: Duration = Duration::from_hours(24);
88
89#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct MetadataUpdate {
96 pub extend_expiry: ExpiryExtension,
98}
99
100#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
105#[serde(untagged, deny_unknown_fields)]
106pub enum ExpiryExtension {
107 At {
109 at: Rfc3339Timestamp,
111 },
112 After {
114 #[serde(with = "crate::duration")]
116 after: Duration,
117 from: ExpiryAnchor,
119 },
120}
121
122#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub enum ExpiryAnchor {
126 Creation,
128 Now,
130}
131
132#[derive(Debug, thiserror::Error)]
134pub enum Error {
135 #[error("error dealing with http headers")]
137 Header(#[from] Option<http::Error>),
138 #[error("invalid expiration policy value")]
140 Expiration(#[from] Option<ParseDurationError>),
141 #[error("invalid compression value")]
143 Compression,
144 #[error("invalid content type")]
146 ContentType(#[from] mediatype::MediaTypeError),
147 #[error("invalid creation time")]
149 CreationTime(#[from] humantime::TimestampError),
150 #[error("invalid expiration time")]
152 ExpirationTime(#[from] InvalidTimestamp),
153 #[error("invalid object size")]
155 Size(#[from] ParseIntError),
156 #[error("invalid metadata header value")]
158 Encoding(#[from] crate::headers::DecodeError),
159 #[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 Self::Header(None)
177 }
178}
179
180#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
198pub enum ExpirationPolicy {
199 #[default]
202 Manual,
203 TimeToLive(Duration),
205 TimeToIdle(Duration),
207}
208impl ExpirationPolicy {
209 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 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 pub fn is_manual(&self) -> bool {
229 *self == ExpirationPolicy::Manual
230 }
231
232 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
289pub enum Compression {
290 Zstd,
292 Gzip,
294}
295
296impl Compression {
297 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
330#[serde(default)]
331pub struct Metadata {
332 #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
336 pub expiration_policy: ExpirationPolicy,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
343 pub time_created: Option<Timestamp>,
344
345 #[serde(skip_serializing_if = "Option::is_none")]
353 pub time_expires: Option<Timestamp>,
354
355 pub content_type: Cow<'static, str>,
359
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub compression: Option<Compression>,
363
364 #[serde(skip_serializing_if = "Option::is_none")]
370 pub origin: Option<String>,
371
372 #[serde(skip_serializing_if = "Option::is_none")]
379 pub filename: Option<String>,
380
381 #[serde(skip_serializing_if = "Option::is_none")]
387 pub size: Option<usize>,
388
389 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
393 pub custom: BTreeMap<String, String>,
394}
395
396impl Metadata {
397 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 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 pub fn is_expired(&self, access_time: Timestamp) -> bool {
445 self.time_expires
446 .is_some_and(|deadline| deadline < access_time)
447 }
448
449 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 pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
461 Self::parse_headers(headers, prefix, false)
462 }
463
464 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 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 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 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 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 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 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 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
598fn 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 #[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 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 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 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 assert_eq!(metadata.content_type, "text/plain");
899 }
900
901 #[test]
902 fn from_insert_headers_ignores_malformed_read_only_fields() {
903 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 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 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 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}