#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/http-fresh/0.1.0")]
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Request<'a> {
pub if_none_match: Option<&'a str>,
pub if_modified_since: Option<&'a str>,
pub cache_control: Option<&'a str>,
}
impl<'a> Request<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn if_none_match(mut self, value: &'a str) -> Self {
self.if_none_match = Some(value);
self
}
#[must_use]
pub fn if_modified_since(mut self, value: &'a str) -> Self {
self.if_modified_since = Some(value);
self
}
#[must_use]
pub fn cache_control(mut self, value: &'a str) -> Self {
self.cache_control = Some(value);
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Response<'a> {
pub etag: Option<&'a str>,
pub last_modified: Option<&'a str>,
}
impl<'a> Response<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn etag(mut self, value: &'a str) -> Self {
self.etag = Some(value);
self
}
#[must_use]
pub fn last_modified(mut self, value: &'a str) -> Self {
self.last_modified = Some(value);
self
}
}
#[must_use]
pub fn fresh(req: &Request<'_>, res: &Response<'_>) -> bool {
let modified_since = present(req.if_modified_since);
let none_match = present(req.if_none_match);
if modified_since.is_none() && none_match.is_none() {
return false;
}
if let Some(cc) = present(req.cache_control) {
if has_no_cache(cc) {
return false;
}
}
if let Some(none_match) = none_match {
if none_match != "*" {
let Some(etag) = present(res.etag) else {
return false;
};
if !none_match
.split(',')
.any(|tok| etag_matches(trim_spaces(tok), etag))
{
return false;
}
}
}
if let Some(modified_since) = modified_since {
let Some(last_modified) = present(res.last_modified) else {
return false;
};
let modified_stale = match (
parse_http_date(last_modified),
parse_http_date(modified_since),
) {
(Some(last), Some(since)) => last > since,
_ => true,
};
if modified_stale {
return false;
}
}
true
}
fn present(value: Option<&str>) -> Option<&str> {
value.filter(|v| !v.is_empty())
}
fn trim_spaces(s: &str) -> &str {
s.trim_matches(' ')
}
fn etag_matches(tag: &str, etag: &str) -> bool {
tag == etag || tag.strip_prefix("W/") == Some(etag) || etag.strip_prefix("W/") == Some(tag)
}
fn has_no_cache(cc: &str) -> bool {
let needle = "no-cache";
let mut search_from = 0;
while let Some(rel) = cc[search_from..].find(needle) {
let start = search_from + rel;
let end = start + needle.len();
let left_ok = {
let mut iter = cc[..start]
.chars()
.rev()
.skip_while(|&c| is_js_whitespace(c));
matches!(iter.next(), None | Some(','))
};
let right_ok = {
let mut iter = cc[end..].chars().skip_while(|&c| is_js_whitespace(c));
matches!(iter.next(), None | Some(','))
};
if left_ok && right_ok {
return true;
}
search_from = start + 1;
}
false
}
fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{0009}'
| '\u{000A}'
| '\u{000B}'
| '\u{000C}'
| '\u{000D}'
| '\u{0020}'
| '\u{00A0}'
| '\u{1680}'
| '\u{2000}'
..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}
fn parse_http_date(s: &str) -> Option<i64> {
parse_imf(s)
.or_else(|| parse_rfc850(s))
.or_else(|| parse_asctime(s))
}
fn parse_imf(s: &str) -> Option<i64> {
let comma = s.find(',')?;
let rest = s[comma + 1..].trim_start_matches(' ');
let mut it = rest.split(' ').filter(|p| !p.is_empty());
let day = parse_fixed_digits(it.next()?, 2)?;
let month = parse_month(it.next()?)?;
let year_str = it.next()?;
if year_str.len() != 4 {
return None;
}
let year = parse_fixed_digits(year_str, 4)?;
let (h, m, sec) = parse_time(it.next()?)?;
if !it.next()?.eq_ignore_ascii_case("GMT") || it.next().is_some() {
return None;
}
make_epoch(i64::from(year), month, i64::from(day), h, m, sec)
}
fn parse_rfc850(s: &str) -> Option<i64> {
let comma = s.find(',')?;
let rest = s[comma + 1..].trim_start_matches(' ');
let mut it = rest.split(' ').filter(|p| !p.is_empty());
let date_part = it.next()?;
let (h, m, sec) = parse_time(it.next()?)?;
if !it.next()?.eq_ignore_ascii_case("GMT") || it.next().is_some() {
return None;
}
let mut dit = date_part.split('-');
let day = parse_fixed_digits(dit.next()?, 2)?;
let month = parse_month(dit.next()?)?;
let yy_str = dit.next()?;
if dit.next().is_some() || yy_str.len() != 2 {
return None;
}
let yy = i64::from(parse_fixed_digits(yy_str, 2)?);
let year = if yy < 50 { 2000 + yy } else { 1900 + yy };
make_epoch(year, month, i64::from(day), h, m, sec)
}
fn parse_asctime(s: &str) -> Option<i64> {
if s.contains(',') {
return None;
}
let mut it = s.split(' ').filter(|p| !p.is_empty());
let _weekday = it.next()?;
let month = parse_month(it.next()?)?;
let day = parse_day_1_or_2(it.next()?)?;
let (h, m, sec) = parse_time(it.next()?)?;
let year_str = it.next()?;
if it.next().is_some() || year_str.len() != 4 {
return None;
}
let year = i64::from(parse_fixed_digits(year_str, 4)?);
make_epoch(year, month, i64::from(day), h, m, sec)
}
fn parse_time(s: &str) -> Option<(i64, i64, i64)> {
let mut it = s.split(':');
let h = parse_fixed_digits(it.next()?, 2)?;
let m = parse_fixed_digits(it.next()?, 2)?;
let sec = parse_fixed_digits(it.next()?, 2)?;
if it.next().is_some() || h > 23 || m > 59 || sec > 60 {
return None;
}
Some((i64::from(h), i64::from(m), i64::from(sec)))
}
fn parse_fixed_digits(s: &str, width: usize) -> Option<u32> {
if s.len() != width || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
fn parse_day_1_or_2(s: &str) -> Option<u32> {
if s.is_empty() || s.len() > 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
fn parse_month(s: &str) -> Option<i64> {
if s.len() != 3 {
return None;
}
let mut buf = [0u8; 3];
for (i, b) in s.bytes().enumerate() {
buf[i] = b.to_ascii_lowercase();
}
match &buf {
b"jan" => Some(1),
b"feb" => Some(2),
b"mar" => Some(3),
b"apr" => Some(4),
b"may" => Some(5),
b"jun" => Some(6),
b"jul" => Some(7),
b"aug" => Some(8),
b"sep" => Some(9),
b"oct" => Some(10),
b"nov" => Some(11),
b"dec" => Some(12),
_ => None,
}
}
fn make_epoch(year: i64, month: i64, day: i64, h: i64, m: i64, s: i64) -> Option<i64> {
if !(1..=31).contains(&day) {
return None;
}
let days = days_from_civil(year, month, day);
Some(days * 86_400 + h * 3_600 + m * 60 + s)
}
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let mp = if month > 2 { month - 3 } else { month + 9 }; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unconditional_request_is_stale() {
assert!(!fresh(&Request::new(), &Response::new()));
}
#[test]
fn star_if_none_match_is_fresh() {
let req = Request::new().if_none_match("*");
assert!(fresh(&req, &Response::new()));
}
#[test]
fn etag_strong_and_weak_matching() {
let req = Request::new().if_none_match("\"foo\"");
assert!(fresh(&req, &Response::new().etag("\"foo\"")));
assert!(!fresh(&req, &Response::new().etag("\"bar\"")));
assert!(fresh(
&Request::new().if_none_match("W/\"foo\""),
&Response::new().etag("\"foo\"")
));
assert!(fresh(&req, &Response::new().etag("W/\"foo\"")));
}
#[test]
fn etag_list_matches_any() {
let req = Request::new().if_none_match("\"a\" , \"b\", \"c\"");
assert!(fresh(&req, &Response::new().etag("\"b\"")));
assert!(!fresh(&req, &Response::new().etag("\"z\"")));
}
#[test]
fn missing_response_etag_is_stale() {
let req = Request::new().if_none_match("\"foo\"");
assert!(!fresh(&req, &Response::new()));
}
#[test]
fn no_cache_forces_stale() {
let res = Response::new().etag("\"foo\"");
assert!(!fresh(
&Request::new()
.if_none_match("\"foo\"")
.cache_control("no-cache"),
&res
));
assert!(!fresh(
&Request::new()
.if_none_match("\"foo\"")
.cache_control("max-age=0, no-cache"),
&res
));
assert!(fresh(
&Request::new()
.if_none_match("\"foo\"")
.cache_control("no-cachex"),
&res
));
assert!(fresh(
&Request::new()
.if_none_match("\"foo\"")
.cache_control("public, max-age=0"),
&res
));
}
#[test]
fn modified_since_dates() {
let res = Response::new().last_modified("Sun, 06 Nov 1994 08:49:37 GMT");
assert!(fresh(
&Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT"),
&res
));
assert!(fresh(
&Request::new().if_modified_since("Mon, 07 Nov 1994 08:49:37 GMT"),
&res
));
assert!(!fresh(
&Request::new().if_modified_since("Sat, 05 Nov 1994 08:49:37 GMT"),
&res
));
}
#[test]
fn missing_last_modified_is_stale() {
let req = Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT");
assert!(!fresh(&req, &Response::new()));
}
#[test]
fn unparseable_date_is_stale() {
let req = Request::new().if_modified_since("not a date");
let res = Response::new().last_modified("Sun, 06 Nov 1994 08:49:37 GMT");
assert!(!fresh(&req, &res));
}
#[test]
fn empty_headers_treated_as_absent() {
assert!(!fresh(
&Request::new().if_modified_since("").if_none_match(""),
&Response::new()
));
}
#[test]
fn all_three_date_formats_parse_equal() {
let imf = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
let rfc850 = parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
let asctime = parse_http_date("Sun Nov 6 08:49:37 1994").unwrap();
assert_eq!(imf, rfc850);
assert_eq!(imf, asctime);
assert_eq!(imf, 784_111_777);
}
#[test]
fn rfc850_two_digit_year_pivot() {
let y49 = parse_http_date("Sun, 06-Nov-49 00:00:00 GMT").unwrap();
let y50 = parse_http_date("Sun, 06-Nov-50 00:00:00 GMT").unwrap();
assert!(y49 > y50); }
}