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