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