1use std::borrow::Cow;
34use std::collections::BTreeMap;
35use std::fmt;
36use std::str::FromStr;
37use std::time::{Duration, SystemTime};
38
39use http::header::{self, HeaderMap, HeaderName};
40use humantime::{format_duration, format_rfc3339_micros, parse_duration, parse_rfc3339};
41use serde::{Deserialize, Serialize};
42
43pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
45pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
47pub const HEADER_TIME_EXPIRES: &str = "x-sn-time-expires";
49pub const HEADER_ORIGIN: &str = "x-sn-origin";
51pub const HEADER_FILENAME: &str = "x-sn-filename";
53pub const HEADER_META_PREFIX: &str = "x-snme-";
55
56pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
58
59#[derive(Debug, thiserror::Error)]
61pub enum Error {
62 #[error("error dealing with http headers")]
64 Header(#[from] Option<http::Error>),
65 #[error("invalid expiration policy value")]
67 Expiration(#[from] Option<humantime::DurationError>),
68 #[error("invalid compression value")]
70 Compression,
71 #[error("invalid content type")]
73 ContentType(#[from] mediatype::MediaTypeError),
74 #[error("invalid creation time")]
76 CreationTime(#[from] humantime::TimestampError),
77 #[error("invariant violation: {0}")]
79 Invariant(&'static str),
80}
81impl From<header::InvalidHeaderValue> for Error {
82 fn from(err: header::InvalidHeaderValue) -> Self {
83 Self::Header(Some(err.into()))
84 }
85}
86impl From<header::InvalidHeaderName> for Error {
87 fn from(err: header::InvalidHeaderName) -> Self {
88 Self::Header(Some(err.into()))
89 }
90}
91impl From<header::ToStrError> for Error {
92 fn from(_err: header::ToStrError) -> Self {
93 Self::Header(None)
95 }
96}
97
98#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116pub enum ExpirationPolicy {
117 #[default]
120 Manual,
121 TimeToLive(Duration),
123 TimeToIdle(Duration),
125}
126impl ExpirationPolicy {
127 pub fn expires_in(&self) -> Option<Duration> {
129 match self {
130 ExpirationPolicy::Manual => None,
131 ExpirationPolicy::TimeToLive(duration) => Some(*duration),
132 ExpirationPolicy::TimeToIdle(duration) => Some(*duration),
133 }
134 }
135
136 pub fn is_timeout(&self) -> bool {
138 match self {
139 ExpirationPolicy::TimeToLive(_) => true,
140 ExpirationPolicy::TimeToIdle(_) => true,
141 ExpirationPolicy::Manual => false,
142 }
143 }
144
145 pub fn is_manual(&self) -> bool {
147 *self == ExpirationPolicy::Manual
148 }
149}
150impl fmt::Display for ExpirationPolicy {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 ExpirationPolicy::TimeToLive(duration) => {
154 write!(f, "ttl:{}", format_duration(*duration))
155 }
156 ExpirationPolicy::TimeToIdle(duration) => {
157 write!(f, "tti:{}", format_duration(*duration))
158 }
159 ExpirationPolicy::Manual => f.write_str("manual"),
160 }
161 }
162}
163impl FromStr for ExpirationPolicy {
164 type Err = Error;
165
166 fn from_str(s: &str) -> Result<Self, Self::Err> {
167 if s == "manual" {
168 return Ok(ExpirationPolicy::Manual);
169 }
170 if let Some(duration) = s.strip_prefix("ttl:") {
171 return Ok(ExpirationPolicy::TimeToLive(parse_duration(duration)?));
172 }
173 if let Some(duration) = s.strip_prefix("tti:") {
174 return Ok(ExpirationPolicy::TimeToIdle(parse_duration(duration)?));
175 }
176 Err(Error::Expiration(None))
177 }
178}
179
180#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
185pub enum Compression {
186 Zstd,
188 }
193
194impl Compression {
195 pub fn as_str(&self) -> &str {
197 match self {
198 Compression::Zstd => "zstd",
199 }
202 }
203}
204
205impl fmt::Display for Compression {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 f.write_str(self.as_str())
208 }
209}
210
211impl FromStr for Compression {
212 type Err = Error;
213
214 fn from_str(s: &str) -> Result<Self, Self::Err> {
215 match s {
216 "zstd" => Ok(Compression::Zstd),
217 _ => Err(Error::Compression),
220 }
221 }
222}
223
224#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
230#[serde(default)]
231pub struct Metadata {
232 #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
236 pub expiration_policy: ExpirationPolicy,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
243 pub time_created: Option<SystemTime>,
244
245 #[serde(skip_serializing_if = "Option::is_none")]
251 pub time_expires: Option<SystemTime>,
252
253 pub content_type: Cow<'static, str>,
257
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub compression: Option<Compression>,
261
262 #[serde(skip_serializing_if = "Option::is_none")]
268 pub origin: Option<String>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
276 pub filename: Option<String>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
283 pub size: Option<usize>,
284
285 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
289 pub custom: BTreeMap<String, String>,
290}
291
292impl Metadata {
293 pub fn from_insert_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
304 let mut metadata = Self::parse_headers(headers, prefix, true)?;
305
306 let now = SystemTime::now();
307 metadata.time_created = Some(now);
308 metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
309
310 Ok(metadata)
311 }
312
313 pub fn validate(&self) -> Result<(), Error> {
319 if self.expiration_policy.is_timeout() && self.time_expires.is_none() {
320 return Err(Error::Invariant(
321 "expiration policy requires a resolved expiration time",
322 ));
323 }
324 Ok(())
325 }
326
327 pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
331 Self::parse_headers(headers, prefix, false)
332 }
333
334 fn parse_headers(
340 headers: &HeaderMap,
341 prefix: &str,
342 skip_read_only: bool,
343 ) -> Result<Self, Error> {
344 let mut metadata = Metadata::default();
345
346 for (name, value) in headers {
347 match *name {
348 header::CONTENT_TYPE => {
350 let content_type = value.to_str()?;
351 validate_content_type(content_type)?;
352 metadata.content_type = content_type.to_owned().into();
353 }
354 header::CONTENT_ENCODING => {
355 let compression = value.to_str()?;
356 metadata.compression = Some(Compression::from_str(compression)?);
357 }
358 _ => {
359 let Some(name) = name.as_str().strip_prefix(prefix) else {
360 continue;
361 };
362
363 match name {
364 HEADER_EXPIRATION => {
366 let expiration_policy = value.to_str()?;
367 metadata.expiration_policy =
368 ExpirationPolicy::from_str(expiration_policy)?;
369 }
370 HEADER_TIME_CREATED if !skip_read_only => {
371 let timestamp = value.to_str()?;
372 let time = parse_rfc3339(timestamp)?;
373 metadata.time_created = Some(time);
374 }
375 HEADER_TIME_EXPIRES if !skip_read_only => {
376 let timestamp = value.to_str()?;
377 let time = parse_rfc3339(timestamp)?;
378 metadata.time_expires = Some(time);
379 }
380 HEADER_ORIGIN => {
381 metadata.origin = Some(value.to_str()?.to_owned());
382 }
383 HEADER_FILENAME => {
384 metadata.filename = Some(value.to_str()?.to_owned());
385 }
386 _ => {
387 if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
389 let value = value.to_str()?;
390 metadata.custom.insert(name.into(), value.into());
391 }
392 }
393 }
394 }
395 }
396 }
397
398 Ok(metadata)
399 }
400
401 pub fn to_headers(&self, prefix: &str) -> Result<HeaderMap, Error> {
406 let Self {
407 content_type,
408 compression,
409 origin,
410 filename,
411 expiration_policy,
412 time_created,
413 time_expires,
414 size: _,
415 custom,
416 } = self;
417
418 let mut headers = HeaderMap::new();
419
420 headers.append(header::CONTENT_TYPE, content_type.parse()?);
422 if let Some(compression) = compression {
423 headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
424 }
425
426 if *expiration_policy != ExpirationPolicy::Manual {
428 let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
429 headers.append(name, expiration_policy.to_string().parse()?);
430 }
431 if let Some(time) = time_created {
432 let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
433 let timestamp = format_rfc3339_micros(*time);
434 headers.append(name, timestamp.to_string().parse()?);
435 }
436 if let Some(time) = time_expires {
437 let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_EXPIRES}"))?;
438 let timestamp = format_rfc3339_micros(*time);
439 headers.append(name, timestamp.to_string().parse()?);
440 }
441 if let Some(origin) = origin {
442 let name = HeaderName::try_from(format!("{prefix}{HEADER_ORIGIN}"))?;
443 headers.append(name, origin.parse()?);
444 }
445 if let Some(filename) = filename {
446 let name = HeaderName::try_from(format!("{prefix}{HEADER_FILENAME}"))?;
447 headers.append(name, filename.parse()?);
448 }
449
450 for (key, value) in custom {
452 let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
453 headers.append(name, value.parse()?);
454 }
455
456 Ok(headers)
457 }
458}
459
460fn validate_content_type(content_type: &str) -> Result<(), Error> {
463 mediatype::MediaType::parse(content_type)?;
464 Ok(())
465}
466
467impl Default for Metadata {
468 fn default() -> Self {
469 Self {
470 expiration_policy: ExpirationPolicy::Manual,
471 time_created: None,
472 time_expires: None,
473 content_type: DEFAULT_CONTENT_TYPE.into(),
474 compression: None,
475 origin: None,
476 filename: None,
477 size: None,
478 custom: BTreeMap::new(),
479 }
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn from_headers_with_origin() {
489 let mut headers = HeaderMap::new();
490 headers.insert("content-type", "text/plain".parse().unwrap());
491 headers.insert(HEADER_ORIGIN, "203.0.113.42".parse().unwrap());
492
493 let metadata = Metadata::from_headers(&headers, "").unwrap();
494 assert_eq!(metadata.origin.as_deref(), Some("203.0.113.42"));
495 assert_eq!(metadata.content_type, "text/plain");
496 }
497
498 #[test]
499 fn from_headers_without_origin() {
500 let mut headers = HeaderMap::new();
501 headers.insert("content-type", "text/plain".parse().unwrap());
502
503 let metadata = Metadata::from_headers(&headers, "").unwrap();
504 assert!(metadata.origin.is_none());
505 }
506
507 #[test]
508 fn to_headers_with_origin() {
509 let metadata = Metadata {
510 origin: Some("203.0.113.42".into()),
511 ..Default::default()
512 };
513
514 let headers = metadata.to_headers("").unwrap();
515 assert_eq!(headers.get(HEADER_ORIGIN).unwrap(), "203.0.113.42");
516 }
517
518 #[test]
519 fn to_headers_without_origin() {
520 let metadata = Metadata::default();
521 let headers = metadata.to_headers("").unwrap();
522 assert!(headers.get(HEADER_ORIGIN).is_none());
523 }
524
525 #[test]
526 fn origin_header_roundtrip() {
527 let metadata = Metadata {
528 origin: Some("203.0.113.42".into()),
529 ..Default::default()
530 };
531
532 let headers = metadata.to_headers("").unwrap();
533 let roundtripped = Metadata::from_headers(&headers, "").unwrap();
534 assert_eq!(roundtripped.origin, metadata.origin);
535 }
536
537 #[test]
538 fn from_headers_with_filename() {
539 let mut headers = HeaderMap::new();
540 headers.insert(HEADER_FILENAME, "report.pdf".parse().unwrap());
541
542 let metadata = Metadata::from_headers(&headers, "").unwrap();
543 assert_eq!(metadata.filename.as_deref(), Some("report.pdf"));
544 }
545
546 #[test]
547 fn from_headers_without_filename() {
548 let headers = HeaderMap::new();
549 let metadata = Metadata::from_headers(&headers, "").unwrap();
550 assert!(metadata.filename.is_none());
551 }
552
553 #[test]
554 fn to_headers_with_filename() {
555 let metadata = Metadata {
556 filename: Some("report.pdf".into()),
557 ..Default::default()
558 };
559
560 let headers = metadata.to_headers("").unwrap();
561 assert_eq!(headers.get(HEADER_FILENAME).unwrap(), "report.pdf");
562 }
563
564 #[test]
565 fn to_headers_without_filename() {
566 let metadata = Metadata::default();
567 let headers = metadata.to_headers("").unwrap();
568 assert!(headers.get(HEADER_FILENAME).is_none());
569 }
570
571 #[test]
572 fn filename_header_roundtrip() {
573 let metadata = Metadata {
574 filename: Some("report.pdf".into()),
575 ..Default::default()
576 };
577
578 let headers = metadata.to_headers("").unwrap();
579 let roundtripped = Metadata::from_headers(&headers, "").unwrap();
580 assert_eq!(roundtripped.filename, metadata.filename);
581 }
582
583 #[test]
584 fn from_headers_content_type_and_encoding() {
585 let mut headers = HeaderMap::new();
586 headers.insert("content-type", "application/json".parse().unwrap());
587 headers.insert("content-encoding", "zstd".parse().unwrap());
588
589 let metadata = Metadata::from_headers(&headers, "").unwrap();
590 assert_eq!(metadata.content_type, "application/json");
591 assert_eq!(metadata.compression, Some(Compression::Zstd));
592 }
593
594 #[test]
595 fn from_headers_expiration_policy() {
596 let mut headers = HeaderMap::new();
597 headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
598
599 let metadata = Metadata::from_headers(&headers, "").unwrap();
600 assert_eq!(
601 metadata.expiration_policy,
602 ExpirationPolicy::TimeToLive(Duration::from_secs(30))
603 );
604 }
605
606 #[test]
607 fn from_headers_timestamps() {
608 let mut headers = HeaderMap::new();
609 headers.insert(
610 HEADER_TIME_CREATED,
611 "2024-01-15T12:00:00.000000Z".parse().unwrap(),
612 );
613 headers.insert(
614 HEADER_TIME_EXPIRES,
615 "2024-01-16T12:00:00.000000Z".parse().unwrap(),
616 );
617
618 let metadata = Metadata::from_headers(&headers, "").unwrap();
619 assert!(metadata.time_created.is_some());
620 assert!(metadata.time_expires.is_some());
621 }
622
623 #[test]
624 fn from_insert_headers_ignores_read_only_fields() {
625 let forged_created = "2024-01-15T12:00:00.000000Z";
628 let mut headers = HeaderMap::new();
629 headers.insert("content-type", "text/plain".parse().unwrap());
630 headers.insert(HEADER_TIME_CREATED, forged_created.parse().unwrap());
631 headers.insert(
632 HEADER_TIME_EXPIRES,
633 "2024-01-16T12:00:00.000000Z".parse().unwrap(),
634 );
635
636 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
637 let created = metadata.time_created.unwrap();
639 assert_ne!(created, parse_rfc3339(forged_created).unwrap());
640 assert!(metadata.time_expires.is_none());
641 assert!(metadata.size.is_none());
642 assert_eq!(metadata.content_type, "text/plain");
644 }
645
646 #[test]
647 fn from_insert_headers_ignores_malformed_read_only_fields() {
648 let mut headers = HeaderMap::new();
650 headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
651 headers.insert(HEADER_TIME_EXPIRES, "not-a-timestamp".parse().unwrap());
652
653 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
654 assert!(metadata.time_created.is_some());
655 assert!(metadata.time_expires.is_none());
656 }
657
658 #[test]
659 fn from_insert_headers_resolves_time_expires_for_ttl() {
660 let mut headers = HeaderMap::new();
661 headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
662
663 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
664 let created = metadata.time_created.unwrap();
665 let expires = metadata.time_expires.unwrap();
666 assert_eq!(expires, created + Duration::from_secs(30));
668 }
669
670 #[test]
671 fn from_insert_headers_resolves_time_expires_for_tti() {
672 let mut headers = HeaderMap::new();
673 headers.insert(HEADER_EXPIRATION, "tti:1h".parse().unwrap());
674
675 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
676 let created = metadata.time_created.unwrap();
677 let expires = metadata.time_expires.unwrap();
678 assert_eq!(expires, created + Duration::from_hours(1));
679 }
680
681 #[test]
682 fn from_insert_headers_manual_leaves_time_expires_none() {
683 let headers = HeaderMap::new();
684 let metadata = Metadata::from_insert_headers(&headers, "").unwrap();
685 assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
686 assert!(metadata.time_expires.is_none());
687 }
688
689 #[test]
690 fn validate_accepts_resolved_timeout() {
691 let metadata = Metadata {
692 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
693 time_expires: Some(SystemTime::now() + Duration::from_secs(30)),
694 ..Default::default()
695 };
696 assert!(metadata.validate().is_ok());
697 }
698
699 #[test]
700 fn validate_accepts_manual_without_expiry() {
701 let metadata = Metadata::default();
702 assert!(metadata.validate().is_ok());
703 }
704
705 #[test]
706 fn validate_rejects_timeout_without_expiry() {
707 let metadata = Metadata {
708 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
709 time_expires: None,
710 ..Default::default()
711 };
712 assert!(matches!(metadata.validate(), Err(Error::Invariant(_))));
713 }
714
715 #[test]
716 fn from_headers_custom_metadata_with_prefix() {
717 let mut headers = HeaderMap::new();
718 let prefix = "x-goog-meta-";
720 let expiration_header: HeaderName = format!("{prefix}{HEADER_EXPIRATION}").parse().unwrap();
721 headers.insert(expiration_header, "tti:1h".parse().unwrap());
722
723 let custom_header: HeaderName = format!("{prefix}{HEADER_META_PREFIX}my-key")
724 .parse()
725 .unwrap();
726 headers.insert(custom_header, "my-value".parse().unwrap());
727
728 let metadata = Metadata::from_headers(&headers, prefix).unwrap();
729 assert_eq!(
730 metadata.expiration_policy,
731 ExpirationPolicy::TimeToIdle(Duration::from_hours(1))
732 );
733 assert_eq!(metadata.custom.get("my-key").unwrap(), "my-value");
734 }
735
736 #[test]
737 fn from_headers_invalid_content_type() {
738 let mut headers = HeaderMap::new();
739 headers.insert("content-type", "not a valid media type!".parse().unwrap());
740
741 let err = Metadata::from_headers(&headers, "").unwrap_err();
742 assert!(matches!(err, Error::ContentType(_)));
743 }
744
745 #[test]
746 fn from_headers_invalid_compression() {
747 let mut headers = HeaderMap::new();
748 headers.insert("content-encoding", "brotli".parse().unwrap());
749
750 let err = Metadata::from_headers(&headers, "").unwrap_err();
751 assert!(matches!(err, Error::Compression));
752 }
753
754 #[test]
755 fn from_headers_invalid_expiration() {
756 let mut headers = HeaderMap::new();
757 headers.insert(HEADER_EXPIRATION, "garbage".parse().unwrap());
758
759 let err = Metadata::from_headers(&headers, "").unwrap_err();
760 assert!(matches!(err, Error::Expiration(_)));
761 }
762
763 #[test]
764 fn from_headers_invalid_timestamp() {
765 let mut headers = HeaderMap::new();
766 headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
767
768 let err = Metadata::from_headers(&headers, "").unwrap_err();
769 assert!(matches!(err, Error::CreationTime(_)));
770 }
771
772 #[test]
773 fn to_headers_all_fields() {
774 let metadata = Metadata {
775 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_mins(1)),
776 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
777 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_060)),
778 content_type: "text/html".into(),
779 compression: Some(Compression::Zstd),
780 origin: Some("10.0.0.1".into()),
781 filename: Some("report.pdf".into()),
782 size: None,
783 custom: BTreeMap::from([("foo".into(), "bar".into())]),
784 };
785
786 let headers = metadata.to_headers("pfx-").unwrap();
787 let map: BTreeMap<_, _> = headers
788 .iter()
789 .map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
790 .collect();
791
792 insta::assert_debug_snapshot!(map, @r#"
793 {
794 "content-encoding": "zstd",
795 "content-type": "text/html",
796 "pfx-x-sn-expiration": "ttl:1m",
797 "pfx-x-sn-filename": "report.pdf",
798 "pfx-x-sn-origin": "10.0.0.1",
799 "pfx-x-sn-time-created": "2023-11-14T22:13:20.000000Z",
800 "pfx-x-sn-time-expires": "2023-11-14T22:14:20.000000Z",
801 "pfx-x-snme-foo": "bar",
802 }
803 "#);
804 }
805
806 #[test]
807 fn full_roundtrip_all_fields() {
808 let prefix = "x-test-";
809 let metadata = Metadata {
810 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(2)),
811 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
812 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_007_200)),
813 content_type: "image/png".into(),
814 compression: Some(Compression::Zstd),
815 origin: Some("192.168.1.1".into()),
816 filename: Some("image.png".into()),
817 size: None,
818 custom: BTreeMap::from([
819 ("key1".into(), "value1".into()),
820 ("key2".into(), "value2".into()),
821 ]),
822 };
823
824 let headers = metadata.to_headers(prefix).unwrap();
825 let roundtripped = Metadata::from_headers(&headers, prefix).unwrap();
826
827 assert_eq!(roundtripped.expiration_policy, metadata.expiration_policy);
828 assert_eq!(roundtripped.content_type, metadata.content_type);
829 assert_eq!(roundtripped.compression, metadata.compression);
830 assert_eq!(roundtripped.origin, metadata.origin);
831 assert_eq!(roundtripped.filename, metadata.filename);
832 assert_eq!(roundtripped.time_created, metadata.time_created);
833 assert_eq!(roundtripped.time_expires, metadata.time_expires);
834 assert_eq!(roundtripped.custom, metadata.custom);
835 }
836
837 #[test]
838 fn from_headers_empty() {
839 let headers = HeaderMap::new();
840 let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
841 assert_eq!(metadata, Metadata::default());
842 }
843
844 #[test]
845 fn from_headers_invalid_time_expires() {
846 let mut headers = HeaderMap::new();
847 let name: HeaderName = format!("x-goog-meta-{HEADER_TIME_EXPIRES}")
848 .parse()
849 .unwrap();
850 headers.insert(name, "not-a-timestamp".parse().unwrap());
851
852 assert!(Metadata::from_headers(&headers, "x-goog-meta-").is_err());
855 }
856
857 #[test]
858 fn serde_roundtrip_default() {
859 let metadata = Metadata::default();
860 let json = serde_json::to_string(&metadata).unwrap();
861 let deserialized: Metadata = serde_json::from_str(&json).unwrap();
862 assert_eq!(deserialized, metadata);
863 }
864
865 #[test]
866 fn serde_roundtrip_all_fields() {
867 let metadata = Metadata {
868 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
869 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
870 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_003_600)),
871 content_type: "application/json".into(),
872 compression: Some(Compression::Zstd),
873 origin: Some("10.0.0.1".into()),
874 filename: Some("data.json".into()),
875 size: Some(1024),
876 custom: BTreeMap::from([("key".into(), "value".into())]),
877 };
878
879 let json = serde_json::to_string(&metadata).unwrap();
880 let deserialized: Metadata = serde_json::from_str(&json).unwrap();
881 assert_eq!(deserialized, metadata);
882 }
883
884 #[test]
885 fn size_not_included_in_headers() {
886 let metadata = Metadata {
887 size: Some(42),
888 ..Default::default()
889 };
890
891 let headers = metadata.to_headers("x-goog-meta-").unwrap();
892 let has_size_header = headers.keys().any(|k| k.as_str().contains("size"));
893 assert!(!has_size_header);
894 }
895
896 #[test]
897 fn default_metadata() {
898 let metadata = Metadata::default();
899 assert_eq!(metadata.content_type, DEFAULT_CONTENT_TYPE);
900 assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
901 assert!(metadata.compression.is_none());
902 assert!(metadata.origin.is_none());
903 assert!(metadata.filename.is_none());
904 assert!(metadata.time_created.is_none());
905 assert!(metadata.time_expires.is_none());
906 assert!(metadata.size.is_none());
907 assert!(metadata.custom.is_empty());
908 }
909
910 #[test]
911 fn expiration_display_roundtrip() {
912 let cases = [
913 ExpirationPolicy::Manual,
914 ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
915 ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
916 ];
917
918 for policy in cases {
919 let displayed = policy.to_string();
920 let parsed: ExpirationPolicy = displayed.parse().unwrap();
921 assert_eq!(parsed, policy);
922 }
923 }
924
925 #[test]
926 fn expiration_parse_invalid() {
927 assert!(ExpirationPolicy::from_str("garbage").is_err());
928 assert!(ExpirationPolicy::from_str("ttl:").is_err());
929 assert!(ExpirationPolicy::from_str("").is_err());
930 }
931
932 #[test]
933 fn expiration_policy_helpers() {
934 assert_eq!(ExpirationPolicy::Manual.expires_in(), None);
935 assert!(ExpirationPolicy::Manual.is_manual());
936 assert!(!ExpirationPolicy::Manual.is_timeout());
937
938 let ttl = ExpirationPolicy::TimeToLive(Duration::from_mins(1));
939 assert_eq!(ttl.expires_in(), Some(Duration::from_mins(1)));
940 assert!(ttl.is_timeout());
941 assert!(!ttl.is_manual());
942
943 let tti = ExpirationPolicy::TimeToIdle(Duration::from_mins(2));
944 assert_eq!(tti.expires_in(), Some(Duration::from_mins(2)));
945 assert!(tti.is_timeout());
946 assert!(!tti.is_manual());
947 }
948
949 #[test]
950 fn compression_display_roundtrip() {
951 let displayed = Compression::Zstd.to_string();
952 assert_eq!(displayed, "zstd");
953 let parsed: Compression = displayed.parse().unwrap();
954 assert_eq!(parsed, Compression::Zstd);
955 }
956
957 #[test]
958 fn compression_parse_invalid() {
959 assert!(Compression::from_str("gzip").is_err());
960 assert!(Compression::from_str("").is_err());
961 }
962}