1use std::borrow::Cow;
33use std::collections::BTreeMap;
34use std::fmt;
35use std::str::FromStr;
36use std::time::{Duration, SystemTime};
37
38use http::header::{self, HeaderMap, HeaderName};
39use humantime::{format_duration, format_rfc3339_micros, parse_duration, parse_rfc3339};
40use serde::{Deserialize, Serialize};
41
42pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
44pub const HEADER_REDIRECT_TOMBSTONE: &str = "x-sn-redirect-tombstone";
46pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
48pub const HEADER_TIME_EXPIRES: &str = "x-sn-time-expires";
50pub const HEADER_ORIGIN: &str = "x-sn-origin";
52pub const HEADER_META_PREFIX: &str = "x-snme-";
54
55pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
57
58#[derive(Debug, thiserror::Error)]
60pub enum Error {
61 #[error("error dealing with http headers")]
63 Header(#[from] Option<http::Error>),
64 #[error("invalid expiration policy value")]
66 Expiration(#[from] Option<humantime::DurationError>),
67 #[error("invalid compression value")]
69 Compression,
70 #[error("invalid content type")]
72 ContentType(#[from] mediatype::MediaTypeError),
73 #[error("invalid creation time")]
75 CreationTime(#[from] humantime::TimestampError),
76}
77impl From<http::header::InvalidHeaderValue> for Error {
78 fn from(err: http::header::InvalidHeaderValue) -> Self {
79 Self::Header(Some(err.into()))
80 }
81}
82impl From<http::header::InvalidHeaderName> for Error {
83 fn from(err: http::header::InvalidHeaderName) -> Self {
84 Self::Header(Some(err.into()))
85 }
86}
87impl From<http::header::ToStrError> for Error {
88 fn from(_err: http::header::ToStrError) -> Self {
89 Self::Header(None)
91 }
92}
93
94#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
112pub enum ExpirationPolicy {
113 #[default]
116 Manual,
117 TimeToLive(Duration),
119 TimeToIdle(Duration),
121}
122impl ExpirationPolicy {
123 pub fn expires_in(&self) -> Option<Duration> {
125 match self {
126 ExpirationPolicy::Manual => None,
127 ExpirationPolicy::TimeToLive(duration) => Some(*duration),
128 ExpirationPolicy::TimeToIdle(duration) => Some(*duration),
129 }
130 }
131
132 pub fn is_timeout(&self) -> bool {
134 match self {
135 ExpirationPolicy::TimeToLive(_) => true,
136 ExpirationPolicy::TimeToIdle(_) => true,
137 ExpirationPolicy::Manual => false,
138 }
139 }
140
141 pub fn is_manual(&self) -> bool {
143 *self == ExpirationPolicy::Manual
144 }
145}
146impl fmt::Display for ExpirationPolicy {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 match self {
149 ExpirationPolicy::TimeToLive(duration) => {
150 write!(f, "ttl:{}", format_duration(*duration))
151 }
152 ExpirationPolicy::TimeToIdle(duration) => {
153 write!(f, "tti:{}", format_duration(*duration))
154 }
155 ExpirationPolicy::Manual => f.write_str("manual"),
156 }
157 }
158}
159impl FromStr for ExpirationPolicy {
160 type Err = Error;
161
162 fn from_str(s: &str) -> Result<Self, Self::Err> {
163 if s == "manual" {
164 return Ok(ExpirationPolicy::Manual);
165 }
166 if let Some(duration) = s.strip_prefix("ttl:") {
167 return Ok(ExpirationPolicy::TimeToLive(parse_duration(duration)?));
168 }
169 if let Some(duration) = s.strip_prefix("tti:") {
170 return Ok(ExpirationPolicy::TimeToIdle(parse_duration(duration)?));
171 }
172 Err(Error::Expiration(None))
173 }
174}
175
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
181pub enum Compression {
182 Zstd,
184 }
189
190impl Compression {
191 pub fn as_str(&self) -> &str {
193 match self {
194 Compression::Zstd => "zstd",
195 }
198 }
199}
200
201impl fmt::Display for Compression {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.write_str(self.as_str())
204 }
205}
206
207impl FromStr for Compression {
208 type Err = Error;
209
210 fn from_str(s: &str) -> Result<Self, Self::Err> {
211 match s {
212 "zstd" => Ok(Compression::Zstd),
213 _ => Err(Error::Compression),
216 }
217 }
218}
219
220#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(default)]
227pub struct Metadata {
228 #[serde(skip_serializing_if = "Option::is_none")]
239 pub is_redirect_tombstone: Option<bool>,
240
241 #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
245 pub expiration_policy: ExpirationPolicy,
246
247 #[serde(skip_serializing_if = "Option::is_none")]
252 pub time_created: Option<SystemTime>,
253
254 #[serde(skip_serializing_if = "Option::is_none")]
260 pub time_expires: Option<SystemTime>,
261
262 pub content_type: Cow<'static, str>,
266
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub compression: Option<Compression>,
270
271 #[serde(skip_serializing_if = "Option::is_none")]
277 pub origin: Option<String>,
278
279 #[serde(skip_serializing_if = "Option::is_none")]
284 pub size: Option<usize>,
285
286 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
290 pub custom: BTreeMap<String, String>,
291}
292
293impl Metadata {
294 pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
300 let mut metadata = Metadata::default();
301
302 for (name, value) in headers {
303 match *name {
304 header::CONTENT_TYPE => {
306 let content_type = value.to_str()?;
307 validate_content_type(content_type)?;
308 metadata.content_type = content_type.to_owned().into();
309 }
310 header::CONTENT_ENCODING => {
311 let compression = value.to_str()?;
312 metadata.compression = Some(Compression::from_str(compression)?);
313 }
314 _ => {
315 let Some(name) = name.as_str().strip_prefix(prefix) else {
316 continue;
317 };
318
319 match name {
320 HEADER_EXPIRATION => {
322 let expiration_policy = value.to_str()?;
323 metadata.expiration_policy =
324 ExpirationPolicy::from_str(expiration_policy)?;
325 }
326 HEADER_TIME_CREATED => {
327 let timestamp = value.to_str()?;
328 let time = parse_rfc3339(timestamp)?;
329 metadata.time_created = Some(time);
330 }
331 HEADER_TIME_EXPIRES => {
332 let timestamp = value.to_str()?;
333 let time = parse_rfc3339(timestamp)?;
334 metadata.time_expires = Some(time);
335 }
336 HEADER_ORIGIN => {
337 metadata.origin = Some(value.to_str()?.to_owned());
338 }
339 _ => {
340 if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
342 let value = value.to_str()?;
343 metadata.custom.insert(name.into(), value.into());
344 }
345 }
346 }
347 }
348 }
349 }
350
351 Ok(metadata)
352 }
353
354 pub fn to_headers(&self, prefix: &str) -> Result<HeaderMap, Error> {
360 let Self {
361 is_redirect_tombstone: _,
362 content_type,
363 compression,
364 origin,
365 expiration_policy,
366 time_created,
367 time_expires,
368 size: _,
369 custom,
370 } = self;
371
372 let mut headers = HeaderMap::new();
373
374 headers.append(header::CONTENT_TYPE, content_type.parse()?);
376 if let Some(compression) = compression {
377 headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
378 }
379
380 if *expiration_policy != ExpirationPolicy::Manual {
382 let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
383 headers.append(name, expiration_policy.to_string().parse()?);
384 }
385 if let Some(time) = time_created {
386 let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
387 let timestamp = format_rfc3339_micros(*time);
388 headers.append(name, timestamp.to_string().parse()?);
389 }
390 if let Some(time) = time_expires {
391 let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_EXPIRES}"))?;
392 let timestamp = format_rfc3339_micros(*time);
393 headers.append(name, timestamp.to_string().parse()?);
394 }
395 if let Some(origin) = origin {
396 let name = HeaderName::try_from(format!("{prefix}{HEADER_ORIGIN}"))?;
397 headers.append(name, origin.parse()?);
398 }
399
400 for (key, value) in custom {
402 let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
403 headers.append(name, value.parse()?);
404 }
405
406 Ok(headers)
407 }
408
409 pub fn is_tombstone(&self) -> bool {
411 self.is_redirect_tombstone == Some(true)
412 }
413}
414
415fn validate_content_type(content_type: &str) -> Result<(), Error> {
418 mediatype::MediaType::parse(content_type)?;
419 Ok(())
420}
421
422impl Default for Metadata {
423 fn default() -> Self {
424 Self {
425 is_redirect_tombstone: None,
426 expiration_policy: ExpirationPolicy::Manual,
427 time_created: None,
428 time_expires: None,
429 content_type: DEFAULT_CONTENT_TYPE.into(),
430 compression: None,
431 origin: None,
432 size: None,
433 custom: BTreeMap::new(),
434 }
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn from_headers_with_origin() {
444 let mut headers = HeaderMap::new();
445 headers.insert("content-type", "text/plain".parse().unwrap());
446 headers.insert(HEADER_ORIGIN, "203.0.113.42".parse().unwrap());
447
448 let metadata = Metadata::from_headers(&headers, "").unwrap();
449 assert_eq!(metadata.origin.as_deref(), Some("203.0.113.42"));
450 assert_eq!(metadata.content_type, "text/plain");
451 }
452
453 #[test]
454 fn from_headers_without_origin() {
455 let mut headers = HeaderMap::new();
456 headers.insert("content-type", "text/plain".parse().unwrap());
457
458 let metadata = Metadata::from_headers(&headers, "").unwrap();
459 assert!(metadata.origin.is_none());
460 }
461
462 #[test]
463 fn to_headers_with_origin() {
464 let metadata = Metadata {
465 origin: Some("203.0.113.42".into()),
466 ..Default::default()
467 };
468
469 let headers = metadata.to_headers("").unwrap();
470 assert_eq!(headers.get(HEADER_ORIGIN).unwrap(), "203.0.113.42");
471 }
472
473 #[test]
474 fn to_headers_without_origin() {
475 let metadata = Metadata::default();
476 let headers = metadata.to_headers("").unwrap();
477 assert!(headers.get(HEADER_ORIGIN).is_none());
478 }
479
480 #[test]
481 fn origin_header_roundtrip() {
482 let metadata = Metadata {
483 origin: Some("203.0.113.42".into()),
484 ..Default::default()
485 };
486
487 let headers = metadata.to_headers("").unwrap();
488 let roundtripped = Metadata::from_headers(&headers, "").unwrap();
489 assert_eq!(roundtripped.origin, metadata.origin);
490 }
491
492 #[test]
493 fn from_headers_content_type_and_encoding() {
494 let mut headers = HeaderMap::new();
495 headers.insert("content-type", "application/json".parse().unwrap());
496 headers.insert("content-encoding", "zstd".parse().unwrap());
497
498 let metadata = Metadata::from_headers(&headers, "").unwrap();
499 assert_eq!(metadata.content_type, "application/json");
500 assert_eq!(metadata.compression, Some(Compression::Zstd));
501 }
502
503 #[test]
504 fn from_headers_expiration_policy() {
505 let mut headers = HeaderMap::new();
506 headers.insert(HEADER_EXPIRATION, "ttl:30s".parse().unwrap());
507
508 let metadata = Metadata::from_headers(&headers, "").unwrap();
509 assert_eq!(
510 metadata.expiration_policy,
511 ExpirationPolicy::TimeToLive(Duration::from_secs(30))
512 );
513 }
514
515 #[test]
516 fn from_headers_timestamps() {
517 let mut headers = HeaderMap::new();
518 headers.insert(
519 HEADER_TIME_CREATED,
520 "2024-01-15T12:00:00.000000Z".parse().unwrap(),
521 );
522 headers.insert(
523 HEADER_TIME_EXPIRES,
524 "2024-01-16T12:00:00.000000Z".parse().unwrap(),
525 );
526
527 let metadata = Metadata::from_headers(&headers, "").unwrap();
528 assert!(metadata.time_created.is_some());
529 assert!(metadata.time_expires.is_some());
530 }
531
532 #[test]
533 fn from_headers_custom_metadata_with_prefix() {
534 let mut headers = HeaderMap::new();
535 let prefix = "x-goog-meta-";
537 let expiration_header: HeaderName = format!("{prefix}{HEADER_EXPIRATION}").parse().unwrap();
538 headers.insert(expiration_header, "tti:1h".parse().unwrap());
539
540 let custom_header: HeaderName = format!("{prefix}{HEADER_META_PREFIX}my-key")
541 .parse()
542 .unwrap();
543 headers.insert(custom_header, "my-value".parse().unwrap());
544
545 let metadata = Metadata::from_headers(&headers, prefix).unwrap();
546 assert_eq!(
547 metadata.expiration_policy,
548 ExpirationPolicy::TimeToIdle(Duration::from_secs(3600))
549 );
550 assert_eq!(metadata.custom.get("my-key").unwrap(), "my-value");
551 }
552
553 #[test]
554 fn from_headers_invalid_content_type() {
555 let mut headers = HeaderMap::new();
556 headers.insert("content-type", "not a valid media type!".parse().unwrap());
557
558 let err = Metadata::from_headers(&headers, "").unwrap_err();
559 assert!(matches!(err, Error::ContentType(_)));
560 }
561
562 #[test]
563 fn from_headers_invalid_compression() {
564 let mut headers = HeaderMap::new();
565 headers.insert("content-encoding", "brotli".parse().unwrap());
566
567 let err = Metadata::from_headers(&headers, "").unwrap_err();
568 assert!(matches!(err, Error::Compression));
569 }
570
571 #[test]
572 fn from_headers_invalid_expiration() {
573 let mut headers = HeaderMap::new();
574 headers.insert(HEADER_EXPIRATION, "garbage".parse().unwrap());
575
576 let err = Metadata::from_headers(&headers, "").unwrap_err();
577 assert!(matches!(err, Error::Expiration(_)));
578 }
579
580 #[test]
581 fn from_headers_invalid_timestamp() {
582 let mut headers = HeaderMap::new();
583 headers.insert(HEADER_TIME_CREATED, "not-a-timestamp".parse().unwrap());
584
585 let err = Metadata::from_headers(&headers, "").unwrap_err();
586 assert!(matches!(err, Error::CreationTime(_)));
587 }
588
589 #[test]
590 fn to_headers_all_fields() {
591 let metadata = Metadata {
592 is_redirect_tombstone: None,
593 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(60)),
594 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
595 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_060)),
596 content_type: "text/html".into(),
597 compression: Some(Compression::Zstd),
598 origin: Some("10.0.0.1".into()),
599 size: None,
600 custom: BTreeMap::from([("foo".into(), "bar".into())]),
601 };
602
603 let headers = metadata.to_headers("pfx-").unwrap();
604 let map: BTreeMap<_, _> = headers
605 .iter()
606 .map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
607 .collect();
608
609 insta::assert_debug_snapshot!(map, @r#"
610 {
611 "content-encoding": "zstd",
612 "content-type": "text/html",
613 "pfx-x-sn-expiration": "ttl:1m",
614 "pfx-x-sn-origin": "10.0.0.1",
615 "pfx-x-sn-time-created": "2023-11-14T22:13:20.000000Z",
616 "pfx-x-sn-time-expires": "2023-11-14T22:14:20.000000Z",
617 "pfx-x-snme-foo": "bar",
618 }
619 "#);
620 }
621
622 #[test]
623 fn full_roundtrip_all_fields() {
624 let prefix = "x-test-";
625 let metadata = Metadata {
626 is_redirect_tombstone: None,
627 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(7200)),
628 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
629 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_007_200)),
630 content_type: "image/png".into(),
631 compression: Some(Compression::Zstd),
632 origin: Some("192.168.1.1".into()),
633 size: None,
634 custom: BTreeMap::from([
635 ("key1".into(), "value1".into()),
636 ("key2".into(), "value2".into()),
637 ]),
638 };
639
640 let headers = metadata.to_headers(prefix).unwrap();
641 let roundtripped = Metadata::from_headers(&headers, prefix).unwrap();
642
643 assert_eq!(roundtripped.expiration_policy, metadata.expiration_policy);
644 assert_eq!(roundtripped.content_type, metadata.content_type);
645 assert_eq!(roundtripped.compression, metadata.compression);
646 assert_eq!(roundtripped.origin, metadata.origin);
647 assert_eq!(roundtripped.time_created, metadata.time_created);
648 assert_eq!(roundtripped.time_expires, metadata.time_expires);
649 assert_eq!(roundtripped.custom, metadata.custom);
650 }
651
652 #[test]
653 fn from_headers_empty() {
654 let headers = HeaderMap::new();
655 let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
656 assert_eq!(metadata, Metadata::default());
657 }
658
659 #[test]
660 fn from_headers_ignores_redirect_tombstone() {
661 let mut headers = HeaderMap::new();
664 let name: HeaderName = format!("x-goog-meta-{HEADER_REDIRECT_TOMBSTONE}")
665 .parse()
666 .unwrap();
667 headers.insert(name, "true".parse().unwrap());
668
669 let metadata = Metadata::from_headers(&headers, "x-goog-meta-").unwrap();
670 assert!(metadata.is_redirect_tombstone.is_none());
671 }
672
673 #[test]
674 fn from_headers_invalid_time_expires() {
675 let mut headers = HeaderMap::new();
676 let name: HeaderName = format!("x-goog-meta-{HEADER_TIME_EXPIRES}")
677 .parse()
678 .unwrap();
679 headers.insert(name, "not-a-timestamp".parse().unwrap());
680
681 assert!(Metadata::from_headers(&headers, "x-goog-meta-").is_err());
684 }
685
686 #[test]
687 fn serde_roundtrip_default() {
688 let metadata = Metadata::default();
689 let json = serde_json::to_string(&metadata).unwrap();
690 let deserialized: Metadata = serde_json::from_str(&json).unwrap();
691 assert_eq!(deserialized, metadata);
692 }
693
694 #[test]
695 fn serde_roundtrip_all_fields() {
696 let metadata = Metadata {
697 is_redirect_tombstone: Some(true),
698 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
699 time_created: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
700 time_expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_003_600)),
701 content_type: "application/json".into(),
702 compression: Some(Compression::Zstd),
703 origin: Some("10.0.0.1".into()),
704 size: Some(1024),
705 custom: BTreeMap::from([("key".into(), "value".into())]),
706 };
707
708 let json = serde_json::to_string(&metadata).unwrap();
709 let deserialized: Metadata = serde_json::from_str(&json).unwrap();
710 assert_eq!(deserialized, metadata);
711 }
712
713 #[test]
714 fn size_not_included_in_headers() {
715 let metadata = Metadata {
716 size: Some(42),
717 ..Default::default()
718 };
719
720 let headers = metadata.to_headers("x-goog-meta-").unwrap();
721 let has_size_header = headers.keys().any(|k| k.as_str().contains("size"));
722 assert!(!has_size_header);
723 }
724
725 #[test]
726 fn default_metadata() {
727 let metadata = Metadata::default();
728 assert_eq!(metadata.content_type, DEFAULT_CONTENT_TYPE);
729 assert_eq!(metadata.expiration_policy, ExpirationPolicy::Manual);
730 assert!(metadata.compression.is_none());
731 assert!(metadata.origin.is_none());
732 assert!(metadata.time_created.is_none());
733 assert!(metadata.time_expires.is_none());
734 assert!(metadata.is_redirect_tombstone.is_none());
735 assert!(metadata.size.is_none());
736 assert!(metadata.custom.is_empty());
737 }
738
739 #[test]
740 fn expiration_display_roundtrip() {
741 let cases = [
742 ExpirationPolicy::Manual,
743 ExpirationPolicy::TimeToLive(Duration::from_secs(30)),
744 ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
745 ];
746
747 for policy in cases {
748 let displayed = policy.to_string();
749 let parsed: ExpirationPolicy = displayed.parse().unwrap();
750 assert_eq!(parsed, policy);
751 }
752 }
753
754 #[test]
755 fn expiration_parse_invalid() {
756 assert!(ExpirationPolicy::from_str("garbage").is_err());
757 assert!(ExpirationPolicy::from_str("ttl:").is_err());
758 assert!(ExpirationPolicy::from_str("").is_err());
759 }
760
761 #[test]
762 fn expiration_policy_helpers() {
763 assert_eq!(ExpirationPolicy::Manual.expires_in(), None);
764 assert!(ExpirationPolicy::Manual.is_manual());
765 assert!(!ExpirationPolicy::Manual.is_timeout());
766
767 let ttl = ExpirationPolicy::TimeToLive(Duration::from_secs(60));
768 assert_eq!(ttl.expires_in(), Some(Duration::from_secs(60)));
769 assert!(ttl.is_timeout());
770 assert!(!ttl.is_manual());
771
772 let tti = ExpirationPolicy::TimeToIdle(Duration::from_secs(120));
773 assert_eq!(tti.expires_in(), Some(Duration::from_secs(120)));
774 assert!(tti.is_timeout());
775 assert!(!tti.is_manual());
776 }
777
778 #[test]
779 fn compression_display_roundtrip() {
780 let displayed = Compression::Zstd.to_string();
781 assert_eq!(displayed, "zstd");
782 let parsed: Compression = displayed.parse().unwrap();
783 assert_eq!(parsed, Compression::Zstd);
784 }
785
786 #[test]
787 fn compression_parse_invalid() {
788 assert!(Compression::from_str("gzip").is_err());
789 assert!(Compression::from_str("").is_err());
790 }
791}