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 }
297
298impl Compression {
299 pub fn as_str(&self) -> &str {
301 match self {
302 Compression::Zstd => "zstd",
303 }
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 _ => Err(Error::Compression),
324 }
325 }
326}
327
328#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(default)]
335pub struct Metadata {
336 #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
340 pub expiration_policy: ExpirationPolicy,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
347 pub time_created: Option<Timestamp>,
348
349 #[serde(skip_serializing_if = "Option::is_none")]
357 pub time_expires: Option<Timestamp>,
358
359 pub content_type: Cow<'static, str>,
363
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub compression: Option<Compression>,
367
368 #[serde(skip_serializing_if = "Option::is_none")]
374 pub origin: Option<String>,
375
376 #[serde(skip_serializing_if = "Option::is_none")]
385 pub filename: Option<String>,
386
387 #[serde(skip_serializing_if = "Option::is_none")]
393 pub size: Option<usize>,
394
395 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
399 pub custom: BTreeMap<String, String>,
400}
401
402impl Metadata {
403 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 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 pub fn is_expired(&self, access_time: Timestamp) -> bool {
451 self.time_expires
452 .is_some_and(|deadline| deadline < access_time)
453 }
454
455 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 pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
467 Self::parse_headers(headers, prefix, false)
468 }
469
470 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 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 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 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 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 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 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 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
604fn 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 #[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 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 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 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 assert_eq!(metadata.content_type, "text/plain");
905 }
906
907 #[test]
908 fn from_insert_headers_ignores_malformed_read_only_fields() {
909 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 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 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 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}