1use std::borrow::Cow;
49use std::collections::BTreeMap;
50use std::fmt;
51use std::num::ParseIntError;
52use std::str::FromStr;
53use std::time::{Duration, SystemTime};
54
55use http::header::{self, HeaderMap, HeaderName};
56use humantime::{format_rfc3339_micros, parse_rfc3339};
57use serde::{Deserialize, Serialize};
58
59use crate::duration::{ParseDurationError, format_duration, parse_duration};
60use crate::headers;
61
62pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
64pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
66pub const HEADER_TIME_EXPIRES: &str = "x-sn-time-expires";
68pub const HEADER_ORIGIN: &str = "x-sn-origin";
70pub const HEADER_FILENAME: &str = "x-sn-filename";
72pub const HEADER_SIZE: &str = "x-sn-size";
74pub const HEADER_META_PREFIX: &str = "x-snme-";
76
77pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
79
80const MAX_TTI_DEBOUNCE: Duration = Duration::from_hours(24);
87
88#[derive(Debug, thiserror::Error)]
90pub enum Error {
91 #[error("error dealing with http headers")]
93 Header(#[from] Option<http::Error>),
94 #[error("invalid expiration policy value")]
96 Expiration(#[from] Option<ParseDurationError>),
97 #[error("invalid compression value")]
99 Compression,
100 #[error("invalid content type")]
102 ContentType(#[from] mediatype::MediaTypeError),
103 #[error("invalid creation time")]
105 CreationTime(#[from] humantime::TimestampError),
106 #[error("invalid object size")]
108 Size(#[from] ParseIntError),
109 #[error("invalid metadata header value")]
111 Encoding(#[from] crate::headers::DecodeError),
112 #[error("invariant violation: {0}")]
114 Invariant(&'static str),
115}
116impl From<header::InvalidHeaderValue> for Error {
117 fn from(err: header::InvalidHeaderValue) -> Self {
118 Self::Header(Some(err.into()))
119 }
120}
121impl From<header::InvalidHeaderName> for Error {
122 fn from(err: header::InvalidHeaderName) -> Self {
123 Self::Header(Some(err.into()))
124 }
125}
126impl From<header::ToStrError> for Error {
127 fn from(_err: header::ToStrError) -> Self {
128 Self::Header(None)
130 }
131}
132
133#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
151pub enum ExpirationPolicy {
152 #[default]
155 Manual,
156 TimeToLive(Duration),
158 TimeToIdle(Duration),
160}
161impl ExpirationPolicy {
162 pub fn expires_in(&self) -> Option<Duration> {
164 match self {
165 ExpirationPolicy::Manual => None,
166 ExpirationPolicy::TimeToLive(duration) => Some(*duration),
167 ExpirationPolicy::TimeToIdle(duration) => Some(*duration),
168 }
169 }
170
171 pub fn is_timeout(&self) -> bool {
173 match self {
174 ExpirationPolicy::TimeToLive(_) => true,
175 ExpirationPolicy::TimeToIdle(_) => true,
176 ExpirationPolicy::Manual => false,
177 }
178 }
179
180 pub fn is_manual(&self) -> bool {
182 *self == ExpirationPolicy::Manual
183 }
184
185 pub fn check_tti_bump(
191 &self,
192 time_expires: Option<SystemTime>,
193 access_time: SystemTime,
194 ) -> Option<SystemTime> {
195 let ExpirationPolicy::TimeToIdle(tti) = *self else {
196 return None;
197 };
198
199 let new_expire_at = access_time + tti;
200 let debounce = (tti / 4).min(MAX_TTI_DEBOUNCE);
201 match time_expires {
202 Some(ts) if ts < new_expire_at - debounce => Some(new_expire_at),
203 _ => None,
204 }
205 }
206}
207impl fmt::Display for ExpirationPolicy {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match self {
210 ExpirationPolicy::TimeToLive(duration) => {
211 write!(f, "ttl:{}", format_duration(*duration))
212 }
213 ExpirationPolicy::TimeToIdle(duration) => {
214 write!(f, "tti:{}", format_duration(*duration))
215 }
216 ExpirationPolicy::Manual => f.write_str("manual"),
217 }
218 }
219}
220impl FromStr for ExpirationPolicy {
221 type Err = Error;
222
223 fn from_str(s: &str) -> Result<Self, Self::Err> {
224 if s == "manual" {
225 return Ok(ExpirationPolicy::Manual);
226 }
227 if let Some(duration) = s.strip_prefix("ttl:") {
228 return Ok(ExpirationPolicy::TimeToLive(parse_duration(duration)?));
229 }
230 if let Some(duration) = s.strip_prefix("tti:") {
231 return Ok(ExpirationPolicy::TimeToIdle(parse_duration(duration)?));
232 }
233 Err(Error::Expiration(None))
234 }
235}
236
237#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
242pub enum Compression {
243 Zstd,
245 }
250
251impl Compression {
252 pub fn as_str(&self) -> &str {
254 match self {
255 Compression::Zstd => "zstd",
256 }
259 }
260}
261
262impl fmt::Display for Compression {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 f.write_str(self.as_str())
265 }
266}
267
268impl FromStr for Compression {
269 type Err = Error;
270
271 fn from_str(s: &str) -> Result<Self, Self::Err> {
272 match s {
273 "zstd" => Ok(Compression::Zstd),
274 _ => Err(Error::Compression),
277 }
278 }
279}
280
281#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
287#[serde(default)]
288pub struct Metadata {
289 #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
293 pub expiration_policy: ExpirationPolicy,
294
295 #[serde(skip_serializing_if = "Option::is_none")]
300 pub time_created: Option<SystemTime>,
301
302 #[serde(skip_serializing_if = "Option::is_none")]
308 pub time_expires: Option<SystemTime>,
309
310 pub content_type: Cow<'static, str>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub compression: Option<Compression>,
318
319 #[serde(skip_serializing_if = "Option::is_none")]
325 pub origin: Option<String>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
336 pub filename: Option<String>,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
344 pub size: Option<usize>,
345
346 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
350 pub custom: BTreeMap<String, String>,
351}
352
353impl Metadata {
354 pub fn from_insert_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
365 let mut metadata = Self::parse_headers(headers, prefix, true)?;
366
367 let now = SystemTime::now();
368 metadata.time_created = Some(now);
369 metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
370
371 Ok(metadata)
372 }
373
374 pub fn validate(&self) -> Result<(), Error> {
380 if self.expiration_policy.is_timeout() && self.time_expires.is_none() {
381 return Err(Error::Invariant(
382 "expiration policy requires a resolved expiration time",
383 ));
384 }
385 Ok(())
386 }
387
388 pub fn check_tti_bump(&self, access_time: SystemTime) -> Option<SystemTime> {
392 self.expiration_policy
393 .check_tti_bump(self.time_expires, access_time)
394 }
395
396 pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
400 Self::parse_headers(headers, prefix, false)
401 }
402
403 fn parse_headers(
409 headers: &HeaderMap,
410 prefix: &str,
411 skip_read_only: bool,
412 ) -> Result<Self, Error> {
413 let mut metadata = Metadata::default();
414
415 for (name, value) in headers {
416 match *name {
417 header::CONTENT_TYPE => {
419 let content_type = value.to_str()?;
420 validate_content_type(content_type)?;
421 metadata.content_type = content_type.to_owned().into();
422 }
423 header::CONTENT_ENCODING => {
424 let compression = value.to_str()?;
425 metadata.compression = Some(Compression::from_str(compression)?);
426 }
427 _ => {
428 let Some(name) = name.as_str().strip_prefix(prefix) else {
429 continue;
430 };
431
432 match name {
433 HEADER_EXPIRATION => {
435 let expiration_policy = value.to_str()?;
436 metadata.expiration_policy =
437 ExpirationPolicy::from_str(expiration_policy)?;
438 }
439 HEADER_TIME_CREATED if !skip_read_only => {
440 let timestamp = value.to_str()?;
441 let time = parse_rfc3339(timestamp)?;
442 metadata.time_created = Some(time);
443 }
444 HEADER_TIME_EXPIRES if !skip_read_only => {
445 let timestamp = value.to_str()?;
446 let time = parse_rfc3339(timestamp)?;
447 metadata.time_expires = Some(time);
448 }
449 HEADER_ORIGIN => {
450 metadata.origin = Some(headers::decode_header_value(value)?);
451 }
452 HEADER_FILENAME => {
453 metadata.filename = Some(headers::decode_header_value(value)?);
454 }
455 HEADER_SIZE if !skip_read_only => {
456 let size = value.to_str()?;
457 metadata.size = Some(size.parse()?);
458 }
459 _ => {
460 if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
462 let value = headers::decode_header_value(value)?;
463 metadata.custom.insert(name.into(), value);
464 }
465 }
466 }
467 }
468 }
469 }
470
471 Ok(metadata)
472 }
473
474 pub fn to_headers(&self, prefix: &str) -> Result<HeaderMap, Error> {
479 let Self {
480 content_type,
481 compression,
482 origin,
483 filename,
484 expiration_policy,
485 time_created,
486 time_expires,
487 size,
488 custom,
489 } = self;
490
491 let mut headers = HeaderMap::new();
492
493 headers.append(header::CONTENT_TYPE, content_type.parse()?);
495 if let Some(compression) = compression {
496 headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
497 }
498
499 if *expiration_policy != ExpirationPolicy::Manual {
501 let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
502 headers.append(name, expiration_policy.to_string().parse()?);
503 }
504 if let Some(time) = time_created {
505 let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
506 let timestamp = format_rfc3339_micros(*time);
507 headers.append(name, timestamp.to_string().parse()?);
508 }
509 if let Some(time) = time_expires {
510 let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_EXPIRES}"))?;
511 let timestamp = format_rfc3339_micros(*time);
512 headers.append(name, timestamp.to_string().parse()?);
513 }
514 if let Some(origin) = origin {
515 let name = HeaderName::try_from(format!("{prefix}{HEADER_ORIGIN}"))?;
516 headers.append(name, headers::encode_header_value(origin));
517 }
518 if let Some(filename) = filename {
519 let name = HeaderName::try_from(format!("{prefix}{HEADER_FILENAME}"))?;
520 headers.append(name, headers::encode_header_value(filename));
521 }
522 if let Some(size) = size {
523 let name = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
524 headers.append(name, size.to_string().parse()?);
525 }
526
527 for (key, value) in custom {
529 let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
530 headers.append(name, headers::encode_header_value(value));
531 }
532
533 Ok(headers)
534 }
535}
536
537fn validate_content_type(content_type: &str) -> Result<(), Error> {
540 mediatype::MediaType::parse(content_type)?;
541 Ok(())
542}
543
544impl Default for Metadata {
545 fn default() -> Self {
546 Self {
547 expiration_policy: ExpirationPolicy::Manual,
548 time_created: None,
549 time_expires: None,
550 content_type: DEFAULT_CONTENT_TYPE.into(),
551 compression: None,
552 origin: None,
553 filename: None,
554 size: None,
555 custom: BTreeMap::new(),
556 }
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn from_headers_with_origin() {
566 let mut headers = HeaderMap::new();
567 headers.insert("content-type", "text/plain".parse().unwrap());
568 headers.insert(HEADER_ORIGIN, "203.0.113.42".parse().unwrap());
569
570 let metadata = Metadata::from_headers(&headers, "").unwrap();
571 assert_eq!(metadata.origin.as_deref(), Some("203.0.113.42"));
572 assert_eq!(metadata.content_type, "text/plain");
573 }
574
575 #[test]
576 fn from_headers_without_origin() {
577 let mut headers = HeaderMap::new();
578 headers.insert("content-type", "text/plain".parse().unwrap());
579
580 let metadata = Metadata::from_headers(&headers, "").unwrap();
581 assert!(metadata.origin.is_none());
582 }
583
584 #[test]
585 fn to_headers_with_origin() {
586 let metadata = Metadata {
587 origin: Some("203.0.113.42".into()),
588 ..Default::default()
589 };
590
591 let headers = metadata.to_headers("").unwrap();
592 assert_eq!(headers.get(HEADER_ORIGIN).unwrap(), "203.0.113.42");
593 }
594
595 #[test]
596 fn to_headers_without_origin() {
597 let metadata = Metadata::default();
598 let headers = metadata.to_headers("").unwrap();
599 assert!(headers.get(HEADER_ORIGIN).is_none());
600 }
601
602 #[test]
603 fn origin_header_roundtrip() {
604 let metadata = Metadata {
605 origin: Some("203.0.113.42".into()),
606 ..Default::default()
607 };
608
609 let headers = metadata.to_headers("").unwrap();
610 let roundtripped = Metadata::from_headers(&headers, "").unwrap();
611 assert_eq!(roundtripped.origin, metadata.origin);
612 }
613
614 #[test]
615 fn from_headers_with_filename() {
616 let mut headers = HeaderMap::new();
617 headers.insert(HEADER_FILENAME, "report.pdf".parse().unwrap());
618
619 let metadata = Metadata::from_headers(&headers, "").unwrap();
620 assert_eq!(metadata.filename.as_deref(), Some("report.pdf"));
621 }
622
623 #[test]
624 fn from_headers_without_filename() {
625 let headers = HeaderMap::new();
626 let metadata = Metadata::from_headers(&headers, "").unwrap();
627 assert!(metadata.filename.is_none());
628 }
629
630 #[test]
631 fn to_headers_with_filename() {
632 let metadata = Metadata {
633 filename: Some("report.pdf".into()),
634 ..Default::default()
635 };
636
637 let headers = metadata.to_headers("").unwrap();
638 assert_eq!(headers.get(HEADER_FILENAME).unwrap(), "report.pdf");
639 }
640
641 #[test]
642 fn to_headers_without_filename() {
643 let metadata = Metadata::default();
644 let headers = metadata.to_headers("").unwrap();
645 assert!(headers.get(HEADER_FILENAME).is_none());
646 }
647
648 #[test]
649 fn filename_header_roundtrip() {
650 let metadata = Metadata {
651 filename: Some("report.pdf".into()),
652 ..Default::default()
653 };
654
655 let headers = metadata.to_headers("").unwrap();
656 let roundtripped = Metadata::from_headers(&headers, "").unwrap();
657 assert_eq!(roundtripped.filename, metadata.filename);
658 }
659
660 #[test]
665 fn free_form_values_are_escaped_on_the_wire() {
666 let metadata = Metadata {
667 origin: Some("Ünknown-源".into()),
668 filename: Some("réport-📄.pdf".into()),
669 custom: BTreeMap::from([("release".to_owned(), "100% vérsion-🚀".to_owned())]),
670 ..Default::default()
671 };
672
673 let headers = metadata.to_headers("").unwrap();
674 assert_eq!(
675 headers.get(HEADER_ORIGIN).unwrap(),
676 "%C3%9Cnknown-%E6%BA%90"
677 );
678 assert_eq!(
679 headers.get(HEADER_FILENAME).unwrap(),
680 "r%C3%A9port-%F0%9F%93%84.pdf",
681 );
682 assert_eq!(
683 headers.get(format!("{HEADER_META_PREFIX}release")).unwrap(),
684 "100%25 v%C3%A9rsion-%F0%9F%9A%80",
685 );
686
687 let roundtripped = Metadata::from_headers(&headers, "").unwrap();
688 assert_eq!(roundtripped.origin, metadata.origin);
689 assert_eq!(roundtripped.filename, metadata.filename);
690 assert_eq!(roundtripped.custom, metadata.custom);
691 }
692
693 #[test]
694 fn from_headers_content_type_and_encoding() {
695 let mut headers = HeaderMap::new();
696 headers.insert("content-type", "application/json".parse().unwrap());
697 headers.insert("content-encoding", "zstd".parse().unwrap());
698
699 let metadata = Metadata::from_headers(&headers, "").unwrap();
700 assert_eq!(metadata.content_type, "application/json");
701 assert_eq!(metadata.compression, Some(Compression::Zstd));
702 }
703
704 #[test]
705 fn from_headers_expiration_policy() {
706 let mut headers = HeaderMap::new();
707 headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
708
709 let metadata = Metadata::from_headers(&headers, "").unwrap();
710 assert_eq!(
711 metadata.expiration_policy,
712 ExpirationPolicy::TimeToLive(Duration::from_secs(30))
713 );
714 }
715
716 #[test]
717 fn expiration_policy_keeps_long_durations_in_days() {
718 let ttl = Duration::from_secs(400 * 86400 + 3600);
719 let policy = ExpirationPolicy::TimeToLive(ttl);
720
721 assert_eq!(policy.to_string(), "ttl:400d 1h");
722 assert_eq!(
723 policy.to_string().parse::<ExpirationPolicy>().unwrap(),
724 policy
725 );
726 }
727
728 #[test]
729 fn expiration_policy_parses_units_that_are_never_emitted() {
730 let policy: ExpirationPolicy = "tti:2weeks".parse().unwrap();
731 assert_eq!(
732 policy,
733 ExpirationPolicy::TimeToIdle(Duration::from_secs(14 * 86400))
734 );
735 assert_eq!(policy.to_string(), "tti:14d");
737 }
738
739 #[test]
740 fn from_headers_timestamps() {
741 let mut headers = HeaderMap::new();
742 headers.insert(
743 HEADER_TIME_CREATED,
744 "2024-01-15T12:00:00.000000Z".parse().unwrap(),
745 );
746 headers.insert(
747 HEADER_TIME_EXPIRES,
748 "2024-01-16T12:00:00.000000Z".parse().unwrap(),
749 );
750
751 let metadata = Metadata::from_headers(&headers, "").unwrap();
752 assert!(metadata.time_created.is_some());
753 assert!(metadata.time_expires.is_some());
754 }
755
756 #[test]
757 fn from_insert_headers_ignores_read_only_fields() {
758 let forged_created = "2024-01-15T12:00:00.000000Z";
761 let mut headers = HeaderMap::new();
762 headers.insert("content-type", "text/plain".parse().unwrap());
763 headers.insert(HEADER_TIME_CREATED, forged_created.parse().unwrap());
764 headers.insert(
765 HEADER_TIME_EXPIRES,
766 "2024-01-16T12:00:00.000000Z".parse().unwrap(),
767 );
768
769 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
770 let created = metadata.time_created.unwrap();
772 assert_ne!(created, parse_rfc3339(forged_created).unwrap());
773 assert!(metadata.time_expires.is_none());
774 assert!(metadata.size.is_none());
775 assert_eq!(metadata.content_type, "text/plain");
777 }
778
779 #[test]
780 fn from_insert_headers_ignores_malformed_read_only_fields() {
781 let mut headers = HeaderMap::new();
783 headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
784 headers.insert(HEADER_TIME_EXPIRES, "not-a-timestamp".parse().unwrap());
785
786 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
787 assert!(metadata.time_created.is_some());
788 assert!(metadata.time_expires.is_none());
789 }
790
791 #[test]
792 fn from_insert_headers_resolves_time_expires_for_ttl() {
793 let mut headers = HeaderMap::new();
794 headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
795
796 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
797 let created = metadata.time_created.unwrap();
798 let expires = metadata.time_expires.unwrap();
799 assert_eq!(expires, created + Duration::from_secs(30));
801 }
802
803 #[test]
804 fn from_insert_headers_resolves_time_expires_for_tti() {
805 let mut headers = HeaderMap::new();
806 headers.insert(HEADER_EXPIRATION, "tti:1h".parse().unwrap());
807
808 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
809 let created = metadata.time_created.unwrap();
810 let expires = metadata.time_expires.unwrap();
811 assert_eq!(expires, created + Duration::from_hours(1));
812 }
813
814 #[test]
815 fn from_insert_headers_manual_leaves_time_expires_none() {
816 let headers = HeaderMap::new();
817 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
818 assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
819 assert!(metadata.time_expires.is_none());
820 }
821
822 #[test]
823 fn validate_accepts_resolved_timeout() {
824 let metadata = Metadata {
825 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
826 time_expires: Some(SystemTime::now() + Duration::from_secs(30)),
827 ..Default::default()
828 };
829 assert!(metadata.validate().is_ok());
830 }
831
832 #[test]
833 fn validate_accepts_manual_without_expiry() {
834 let metadata = Metadata::default();
835 assert!(metadata.validate().is_ok());
836 }
837
838 #[test]
839 fn validate_rejects_timeout_without_expiry() {
840 let metadata = Metadata {
841 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
842 time_expires: None,
843 ..Default::default()
844 };
845 assert!(matches!(metadata.validate(), Err(Error::Invariant(_))));
846 }
847
848 #[test]
849 fn from_headers_custom_metadata_with_prefix() {
850 let mut headers = HeaderMap::new();
851 let prefix = "x-goog-meta-";
853 let expiration_header: HeaderName = format!("{prefix}{HEADER_EXPIRATION}").parse().unwrap();
854 headers.insert(expiration_header, "tti:1h".parse().unwrap());
855
856 let custom_header: HeaderName = format!("{prefix}{HEADER_META_PREFIX}my-key")
857 .parse()
858 .unwrap();
859 headers.insert(custom_header, "my-value".parse().unwrap());
860
861 let metadata = Metadata::from_headers(&headers, prefix).unwrap();
862 assert_eq!(
863 metadata.expiration_policy,
864 ExpirationPolicy::TimeToIdle(Duration::from_hours(1))
865 );
866 assert_eq!(metadata.custom.get("my-key").unwrap(), "my-value");
867 }
868
869 #[test]
870 fn from_headers_invalid_content_type() {
871 let mut headers = HeaderMap::new();
872 headers.insert("content-type", "not a valid media type!".parse().unwrap());
873
874 let err = Metadata::from_headers(&headers, "").unwrap_err();
875 assert!(matches!(err, Error::ContentType(_)));
876 }
877
878 #[test]
879 fn from_headers_invalid_compression() {
880 let mut headers = HeaderMap::new();
881 headers.insert("content-encoding", "brotli".parse().unwrap());
882
883 let err = Metadata::from_headers(&headers, "").unwrap_err();
884 assert!(matches!(err, Error::Compression));
885 }
886
887 #[test]
888 fn from_headers_invalid_expiration() {
889 let mut headers = HeaderMap::new();
890 headers.insert(HEADER_EXPIRATION, "garbage".parse().unwrap());
891
892 let err = Metadata::from_headers(&headers, "").unwrap_err();
893 assert!(matches!(err, Error::Expiration(_)));
894 }
895
896 #[test]
897 fn from_headers_invalid_timestamp() {
898 let mut headers = HeaderMap::new();
899 headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
900
901 let err = Metadata::from_headers(&headers, "").unwrap_err();
902 assert!(matches!(err, Error::CreationTime(_)));
903 }
904
905 #[test]
906 fn to_headers_all_fields() {
907 let metadata = Metadata {
908 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_mins(1)),
909 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
910 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_060)),
911 content_type: "text/html".into(),
912 compression: Some(Compression::Zstd),
913 origin: Some("10.0.0.1".into()),
914 filename: Some("report.pdf".into()),
915 size: None,
916 custom: BTreeMap::from([("foo".into(), "bar".into())]),
917 };
918
919 let headers = metadata.to_headers("pfx-").unwrap();
920 let map: BTreeMap<_, _> = headers
921 .iter()
922 .map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
923 .collect();
924
925 insta::assert_debug_snapshot!(map, @r#"
926 {
927 "content-encoding": "zstd",
928 "content-type": "text/html",
929 "pfx-x-sn-expiration": "ttl:1m",
930 "pfx-x-sn-filename": "report.pdf",
931 "pfx-x-sn-origin": "10.0.0.1",
932 "pfx-x-sn-time-created": "2023-11-14T22:13:20.000000Z",
933 "pfx-x-sn-time-expires": "2023-11-14T22:14:20.000000Z",
934 "pfx-x-snme-foo": "bar",
935 }
936 "#);
937 }
938
939 #[test]
940 fn full_roundtrip_all_fields() {
941 let prefix = "x-test-";
942 let metadata = Metadata {
943 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(2)),
944 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
945 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_007_200)),
946 content_type: "image/png".into(),
947 compression: Some(Compression::Zstd),
948 origin: Some("192.168.1.1".into()),
949 filename: Some("image.png".into()),
950 size: None,
951 custom: BTreeMap::from([
952 ("key1".into(), "value1".into()),
953 ("key2".into(), "value2".into()),
954 ]),
955 };
956
957 let headers = metadata.to_headers(prefix).unwrap();
958 let roundtripped = Metadata::from_headers(&headers, prefix).unwrap();
959
960 assert_eq!(roundtripped.expiration_policy, metadata.expiration_policy);
961 assert_eq!(roundtripped.content_type, metadata.content_type);
962 assert_eq!(roundtripped.compression, metadata.compression);
963 assert_eq!(roundtripped.origin, metadata.origin);
964 assert_eq!(roundtripped.filename, metadata.filename);
965 assert_eq!(roundtripped.time_created, metadata.time_created);
966 assert_eq!(roundtripped.time_expires, metadata.time_expires);
967 assert_eq!(roundtripped.custom, metadata.custom);
968 }
969
970 #[test]
971 fn from_headers_empty() {
972 let headers = HeaderMap::new();
973 let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
974 assert_eq!(metadata, Metadata::default());
975 }
976
977 #[test]
978 fn from_headers_invalid_time_expires() {
979 let mut headers = HeaderMap::new();
980 let name: HeaderName = format!("x-goog-meta-{HEADER_TIME_EXPIRES}")
981 .parse()
982 .unwrap();
983 headers.insert(name, "not-a-timestamp".parse().unwrap());
984
985 assert!(Metadata::from_headers(&headers, "x-goog-meta-").is_err());
988 }
989
990 #[test]
991 fn serde_roundtrip_default() {
992 let metadata = Metadata::default();
993 let json = serde_json::to_string(&metadata).unwrap();
994 let deserialized: Metadata = serde_json::from_str(&json).unwrap();
995 assert_eq!(deserialized, metadata);
996 }
997
998 #[test]
999 fn serde_roundtrip_all_fields() {
1000 let metadata = Metadata {
1001 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1002 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
1003 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_003_600)),
1004 content_type: "application/json".into(),
1005 compression: Some(Compression::Zstd),
1006 origin: Some("10.0.0.1".into()),
1007 filename: Some("data.json".into()),
1008 size: Some(1024),
1009 custom: BTreeMap::from([("key".into(), "value".into())]),
1010 };
1011
1012 let json = serde_json::to_string(&metadata).unwrap();
1013 let deserialized: Metadata = serde_json::from_str(&json).unwrap();
1014 assert_eq!(deserialized, metadata);
1015 }
1016
1017 #[test]
1018 fn size_roundtrips_through_headers() {
1019 let metadata = Metadata {
1020 size: Some(42),
1021 ..Default::default()
1022 };
1023
1024 let headers = metadata.to_headers("").unwrap();
1025 assert_eq!(headers.get(HEADER_SIZE).unwrap(), "42");
1026 assert_eq!(Metadata::from_headers(&headers, "").unwrap().size, Some(42));
1027 }
1028
1029 #[test]
1030 fn size_is_prefixed_in_headers() {
1031 let metadata = Metadata {
1032 size: Some(42),
1033 ..Default::default()
1034 };
1035
1036 let headers = metadata.to_headers("x-goog-meta-").unwrap();
1037 assert_eq!(headers.get("x-goog-meta-x-sn-size").unwrap(), "42");
1038 }
1039
1040 #[test]
1041 fn from_insert_headers_ignores_size() {
1042 let mut headers = HeaderMap::new();
1044 headers.insert(HEADER_SIZE, "9999".parse().unwrap());
1045
1046 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
1047 assert!(metadata.size.is_none());
1048 }
1049
1050 #[test]
1051 fn from_headers_rejects_malformed_size() {
1052 let mut headers = HeaderMap::new();
1053 headers.insert(HEADER_SIZE, "not-a-number".parse().unwrap());
1054
1055 assert!(matches!(
1056 Metadata::from_headers(&headers, ""),
1057 Err(Error::Size(_))
1058 ));
1059 }
1060
1061 #[test]
1062 fn default_metadata() {
1063 let metadata = Metadata::default();
1064 assert_eq!(metadata.content_type, DEFAULT_CONTENT_TYPE);
1065 assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
1066 assert!(metadata.compression.is_none());
1067 assert!(metadata.origin.is_none());
1068 assert!(metadata.filename.is_none());
1069 assert!(metadata.time_created.is_none());
1070 assert!(metadata.time_expires.is_none());
1071 assert!(metadata.size.is_none());
1072 assert!(metadata.custom.is_empty());
1073 }
1074
1075 #[test]
1076 fn expiration_display_roundtrip() {
1077 let cases = [
1078 ExpirationPolicy::Manual,
1079 ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
1080 ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1081 ];
1082
1083 for policy in cases {
1084 let displayed = policy.to_string();
1085 let parsed: ExpirationPolicy = displayed.parse().unwrap();
1086 assert_eq!(parsed, policy);
1087 }
1088 }
1089
1090 #[test]
1091 fn expiration_parse_invalid() {
1092 assert!(ExpirationPolicy::from_str("garbage").is_err());
1093 assert!(ExpirationPolicy::from_str("ttl:").is_err());
1094 assert!(ExpirationPolicy::from_str("").is_err());
1095 }
1096
1097 #[test]
1098 fn expiration_policy_helpers() {
1099 assert_eq!(ExpirationPolicy::Manual.expires_in(), None);
1100 assert!(ExpirationPolicy::Manual.is_manual());
1101 assert!(!ExpirationPolicy::Manual.is_timeout());
1102
1103 let ttl = ExpirationPolicy::TimeToLive(Duration::from_mins(1));
1104 assert_eq!(ttl.expires_in(), Some(Duration::from_mins(1)));
1105 assert!(ttl.is_timeout());
1106 assert!(!ttl.is_manual());
1107
1108 let tti = ExpirationPolicy::TimeToIdle(Duration::from_mins(2));
1109 assert_eq!(tti.expires_in(), Some(Duration::from_mins(2)));
1110 assert!(tti.is_timeout());
1111 assert!(!tti.is_manual());
1112 }
1113
1114 #[test]
1115 fn compression_display_roundtrip() {
1116 let displayed = Compression::Zstd.to_string();
1117 assert_eq!(displayed, "zstd");
1118 let parsed: Compression = displayed.parse().unwrap();
1119 assert_eq!(parsed, Compression::Zstd);
1120 }
1121
1122 #[test]
1123 fn compression_parse_invalid() {
1124 assert!(Compression::from_str("gzip").is_err());
1125 assert!(Compression::from_str("").is_err());
1126 }
1127
1128 #[test]
1129 fn check_tti_bump_returns_none_for_manual() {
1130 let metadata = Metadata::default();
1131 assert!(metadata.check_tti_bump(SystemTime::now()).is_none());
1132 }
1133
1134 #[test]
1135 fn check_tti_bump_returns_none_for_ttl() {
1136 let now = SystemTime::now();
1137 let metadata = Metadata {
1138 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1139 time_expires: Some(now + Duration::from_hours(1)),
1140 ..Default::default()
1141 };
1142 assert!(metadata.check_tti_bump(now).is_none());
1143 }
1144
1145 #[test]
1146 fn check_tti_bump_returns_none_when_fresh() {
1147 let now = SystemTime::now();
1148 let tti = Duration::from_hours(2 * 24);
1149 let metadata = Metadata {
1150 expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1151 time_expires: Some(now + tti),
1152 ..Default::default()
1153 };
1154 assert!(metadata.check_tti_bump(now).is_none());
1155 }
1156
1157 #[test]
1158 fn check_tti_bump_returns_new_deadline_when_stale() {
1159 let now = SystemTime::now();
1160 let tti = Duration::from_hours(2 * 24);
1161 let debounce = tti / 4;
1162 let stale_deadline = now + tti - debounce - Duration::from_mins(1);
1163 let metadata = Metadata {
1164 expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1165 time_expires: Some(stale_deadline),
1166 ..Default::default()
1167 };
1168 let new_deadline = metadata.check_tti_bump(now).unwrap();
1169 assert_eq!(new_deadline, now + tti);
1170 }
1171
1172 #[test]
1173 fn check_tti_bump_short_tti_triggers_bump() {
1174 let now = SystemTime::now();
1175 let tti = Duration::from_hours(2);
1176 let debounce = tti / 4;
1177 let stale_deadline = now + tti - debounce - Duration::from_mins(1);
1178 let metadata = Metadata {
1179 expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1180 time_expires: Some(stale_deadline),
1181 ..Default::default()
1182 };
1183 let new_deadline = metadata.check_tti_bump(now).unwrap();
1184 assert_eq!(new_deadline, now + tti);
1185 }
1186
1187 #[test]
1188 fn check_tti_bump_debounce_caps_at_24h() {
1189 let now = SystemTime::now();
1190 let tti = Duration::from_hours(30 * 24);
1191 let capped_debounce = Duration::from_hours(24);
1192 let stale_deadline = now + tti - capped_debounce - Duration::from_mins(1);
1193 let metadata = Metadata {
1194 expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1195 time_expires: Some(stale_deadline),
1196 ..Default::default()
1197 };
1198 assert!(metadata.check_tti_bump(now).is_some());
1199
1200 let fresh_deadline = now + tti - capped_debounce + Duration::from_mins(1);
1201 let metadata = Metadata {
1202 time_expires: Some(fresh_deadline),
1203 ..metadata
1204 };
1205 assert!(metadata.check_tti_bump(now).is_none());
1206 }
1207
1208 #[test]
1209 fn check_tti_bump_returns_none_when_time_expires_missing() {
1210 let metadata = Metadata {
1211 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1212 time_expires: None,
1213 ..Default::default()
1214 };
1215 assert!(metadata.check_tti_bump(SystemTime::now()).is_none());
1216 }
1217}