From 851f7ba5b99bcdc8333de2d05684e37bcf95844b Mon Sep 17 00:00:00 2001 From: Cosmic Horror Date: Sat, 6 Apr 2024 23:42:36 -0600 Subject: [PATCH] test(image_cache): Add tests for image cache headers --- src/image/cache/global/mod.rs | 2 +- src/image/cache/headers.rs | 161 +++++++++++++++++++++++++++++----- src/image/cache/mod.rs | 2 +- 3 files changed, 140 insertions(+), 25 deletions(-) diff --git a/src/image/cache/global/mod.rs b/src/image/cache/global/mod.rs index 41d3a290..f1ed7722 100644 --- a/src/image/cache/global/mod.rs +++ b/src/image/cache/global/mod.rs @@ -1,6 +1,6 @@ use std::{cmp::Ordering, fs}; -use super::{Key, ValidatedImage, Validation, ValidationProbe}; +use super::{Key, Validation, ValidationProbe}; use crate::{image::ImageData, utils}; use anyhow::Context; diff --git a/src/image/cache/headers.rs b/src/image/cache/headers.rs index 2812ae34..77f2088e 100644 --- a/src/image/cache/headers.rs +++ b/src/image/cache/headers.rs @@ -1,11 +1,12 @@ use std::{ + fmt, str::{FromStr, Split}, time::{Duration, SystemTime}, }; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct CacheControlMeta { e_tag: Option, stale_after: SystemTime, @@ -13,14 +14,28 @@ pub struct CacheControlMeta { impl CacheControlMeta { pub fn from_resp(resp: &ureq::Response) -> Option { - let e_tag = resp.header("ETag").map(Into::into); - let age = resp.header("Age").and_then(|age| { + Self::from_e_tag_age_and_cache_control_with_time( + resp.header("ETag"), + resp.header("Age"), + resp.header("Cache-Control"), + SystemTime::now(), + ) + } + + fn from_e_tag_age_and_cache_control_with_time( + e_tag: Option<&str>, + age: Option<&str>, + cache_control: Option<&str>, + time: SystemTime, + ) -> Option { + let e_tag = e_tag.map(Into::into); + let age = age.and_then(|age| { age.parse::() .inspect_err(|err| tracing::info!("Error parsing `Age`: {err:?}")) .ok() }); let mut max_age = None; - if let Some(cache_control) = resp.header("Cache-Control") { + if let Some(cache_control) = cache_control { for directive in CacheControlIter::new(cache_control) { match directive { CacheControlDirective::NoStore => return None, @@ -35,20 +50,36 @@ impl CacheControlMeta { if let Some(age) = age { max_age = max_age.checked_sub(age.0)?; } - let now = SystemTime::now(); - let stale_after = now + max_age; + let stale_after = time + max_age; Some(Self { stale_after, e_tag }) }) } + + fn from_e_tag_life_for_and_time( + e_tag: Option, + live_for: Duration, + time: SystemTime, + ) -> Self { + let stale_after = time + live_for; + Self { e_tag, stale_after } + } } /// Represents the `Age` header /// /// Can be used in tandem with `Cache-Control` to indicate the current age of the request -struct Age(pub Duration); +struct Age(Duration); #[derive(Debug)] -struct UnknownAge(pub String); +struct UnknownAge(String); + +impl fmt::Display for UnknownAge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Unknown age: {}", self.0) + } +} + +impl std::error::Error for UnknownAge {} impl FromStr for Age { type Err = UnknownAge; @@ -65,7 +96,7 @@ impl FromStr for Age { /// tags identify that the content is exactly identical meaning that it can be re-used for things /// like byte-range requests // NOTE: We currently ignore weak/strong as we don't need any of "strong"'s guarantees -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct ETag(String); impl<'a> From<&'a str> for ETag { @@ -141,6 +172,17 @@ enum CacheControlParseError { UnknownAge(UnknownAge), } +impl fmt::Display for CacheControlParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownDirective(unknown) => write!(f, "Unknown directive: {unknown}"), + Self::UnknownAge(age) => write!(f, "{age}"), + } + } +} + +impl std::error::Error for CacheControlParseError {} + impl FromStr for CacheControlDirective { type Err = CacheControlParseError; @@ -158,18 +200,10 @@ impl FromStr for CacheControlDirective { Ok(Self::MaxAge(age)) } ("no-store", None) => Ok(Self::NoStore), + ("stale-while-revalidate", Some(_)) => Ok(Self::Ignored), ( - "s-max-age" - | "no-cache" - | "must-revalidate" - | "proxy-revalidate" - | "private" - | "public" - | "must-understand" - | "no-transform" - | "immutable" - | "stale-while-revalidate" - | "stale-if-error", + "s-max-age" | "no-cache" | "must-revalidate" | "proxy-revalidate" | "private" + | "public" | "must-understand" | "no-transform" | "immutable" | "stale-if-error", None, ) => Ok(Self::Ignored), _ => Err(CacheControlParseError::UnknownDirective(s.to_owned())), @@ -181,8 +215,89 @@ impl FromStr for CacheControlDirective { mod tests { use super::*; - #[test] - fn sanity() { - todo!("Split out parsing logic into its own function and test against that"); + macro_rules! c { + ($test_name:ident, $headers:expr, expect: None) => { + c!($test_name, $headers, None); + }; + ($test_name:ident, $headers:expr, expect: (live_for: $live_for:expr)) => { + c!($test_name, $headers, expect: (None, $live_for)); + }; + ($test_name:ident, $headers:expr, expect: ($e_tag:expr, $live_for:expr)) => { + c!($test_name, $headers, Some(CacheControlMeta::from_e_tag_life_for_and_time( + $e_tag, + $live_for, + SystemTime::UNIX_EPOCH, + ))); + }; + ($test_name:ident, $headers:expr, $expect:expr) => { + #[test] + fn $test_name() { + $crate::test_utils::init_test_log(); + + let Headers { e_tag, age, cache_control } = $headers; + let cache_meta = CacheControlMeta::from_e_tag_age_and_cache_control_with_time( + e_tag, + age, + cache_control, + SystemTime::UNIX_EPOCH, + ); + assert_eq!($expect, cache_meta); + } + }; + } + + fn h() -> Headers { + Headers::default() + } + + #[derive(Default)] + struct Headers { + e_tag: Option<&'static str>, + age: Option<&'static str>, + cache_control: Option<&'static str>, } + + impl Headers { + fn e_tag(mut self, e_tag: &'static str) -> Self { + self.e_tag = Some(e_tag); + self + } + + fn age(mut self, age: &'static str) -> Self { + self.age = Some(age); + self + } + + fn cache(mut self, c_c: &'static str) -> Self { + self.cache_control = Some(c_c); + self + } + } + + c!(plain_no_store, h().cache("no-store"), expect: None); + c!(invalid_age, h().age(r#""100""#), expect: None); + c!(invalid_max_age, h().cache(r#"max-age="100""#), expect: None); + c!(age_past_life, h().age("6").cache("max-age=5"), expect: None); + c!(no_must_understand, h().cache("must-understand, no-store"), expect: None); + c!(unknown_directive, h().cache("invalid-directive=100"), expect: None); + + static E_TAG: &str = r#"W/"f855e1c49b6a108e1f4f8ac2e759be3275830224c21ac88a5c15bf8a2e6ee30d""#; + static ONE_WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60); + static HUNDRED_SECS: Duration = Duration::from_secs(100); + + c!(sanity, h().cache("max-age=604800"), expect: (live_for: ONE_WEEK)); + c!(directive_case_insensitive, h().cache("MaX-aGe=604800"), expect: (live_for: ONE_WEEK)); + c!(e_tag, h().cache("max-age=100").e_tag(E_TAG), expect: (Some(E_TAG.into()), HUNDRED_SECS)); + + c!( + age_and_max_age, + h().cache("max-age=604800").age("100"), + expect: (live_for: ONE_WEEK - HUNDRED_SECS) + ); + + c!( + several_ignored_directives, + h().cache("max-age=100, must-revalidate, private"), + expect: (live_for: HUNDRED_SECS) + ); } diff --git a/src/image/cache/mod.rs b/src/image/cache/mod.rs index d81e7e0b..648a4aeb 100644 --- a/src/image/cache/mod.rs +++ b/src/image/cache/mod.rs @@ -81,7 +81,7 @@ impl ValidatedImage { /// A probe meant to store some short-lived information for checking if cache entries are valid // TODO: store system time and #[derive(Debug)] -struct ValidationProbe(()); +pub struct ValidationProbe(()); impl<'a> TryFrom<&Key<'a>> for ValidationProbe { type Error = anyhow::Error;