use std::collections::BTreeMap;
use std::time::SystemTime;
use chrono::{DateTime, Utc};
const IMF_FIXDATE: &str = "%a, %d %b %Y %H:%M:%S GMT";
const OBSOLETE_HTTP_DATES: [&str; 2] = ["%a, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"];
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Range {
Whole,
Slice { start: u64, end: u64 },
Unsatisfiable,
}
pub(super) fn range_of(header: Option<&String>, len: u64) -> Range {
let Some(value) = header else {
return Range::Whole;
};
match spec_of(value) {
Spec::Ignored => Range::Whole,
_ if len == 0 => Range::Unsatisfiable,
Spec::Suffix(0) => Range::Unsatisfiable,
Spec::Suffix(n) => Range::Slice {
start: len - n.min(len),
end: len - 1,
},
Spec::From(first) if first >= len => Range::Unsatisfiable,
Spec::From(first) => Range::Slice {
start: first,
end: len - 1,
},
Spec::Span { first, last } if first >= len || last < first => Range::Unsatisfiable,
Spec::Span { first, last } => Range::Slice {
start: first,
end: last.min(len - 1),
},
}
}
enum Spec {
Ignored,
Suffix(u64),
From(u64),
Span { first: u64, last: u64 },
}
fn spec_of(value: &str) -> Spec {
let Some(spec) = value.strip_prefix("bytes=") else {
return Spec::Ignored;
};
let Some((first, last)) = spec.split_once('-') else {
return Spec::Ignored;
};
match (first.is_empty(), last.is_empty()) {
(true, true) => Spec::Ignored,
(true, false) => digits(last).map_or(Spec::Ignored, Spec::Suffix),
(false, true) => digits(first).map_or(Spec::Ignored, Spec::From),
(false, false) => match (digits(first), digits(last)) {
(Some(first), Some(last)) => Spec::Span { first, last },
_ => Spec::Ignored,
},
}
}
fn digits(token: &str) -> Option<u64> {
if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
token.parse().ok()
}
#[derive(Debug)]
pub(super) struct Validators {
etag: String,
modified: Option<i64>,
}
impl Validators {
pub(super) fn new(len: u64, modified: Option<SystemTime>) -> Self {
let modified = modified.map(|when| DateTime::<Utc>::from(when).timestamp());
let etag = match modified {
Some(secs) => format!("W/\"{len}-{secs}\""),
None => format!("W/\"{len}\""),
};
Self { etag, modified }
}
pub(super) fn etag(&self) -> &str {
&self.etag
}
pub(super) fn last_modified(&self) -> Option<String> {
self.modified
.and_then(|secs| DateTime::from_timestamp(secs, 0))
.map(|when| when.format(IMF_FIXDATE).to_string())
}
pub(super) fn none_match_agrees(&self, header: &str) -> bool {
header.split(',').any(|item| {
let item = item.trim();
item == "*" || tag_value(item) == tag_value(&self.etag)
})
}
pub(super) fn not_newer_than(&self, header: &str) -> bool {
match (self.modified, parse_http_date(header)) {
(Some(secs), Some(since)) => secs <= since,
_ => false,
}
}
pub(super) fn if_range_agrees(&self, header: &str) -> bool {
let header = header.trim();
if header.contains('"') {
return false;
}
match (self.modified, parse_http_date(header)) {
(Some(secs), Some(if_range)) => secs == if_range,
_ => false,
}
}
}
fn tag_value(tag: &str) -> &str {
tag.strip_prefix("W/").unwrap_or(tag)
}
fn parse_http_date(value: &str) -> Option<i64> {
let value = value.trim();
std::iter::once(IMF_FIXDATE)
.chain(OBSOLETE_HTTP_DATES)
.find_map(|format| chrono::NaiveDateTime::parse_from_str(value, format).ok())
.map(|when| when.and_utc().timestamp())
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Outcome {
Whole,
Partial { start: u64, end: u64 },
Unsatisfiable,
NotModified,
}
pub(super) fn decide(
headers: &BTreeMap<String, String>,
len: u64,
validators: &Validators,
) -> Outcome {
if let Some(if_none_match) = headers.get("if-none-match") {
if validators.none_match_agrees(if_none_match) {
return Outcome::NotModified;
}
} else if let Some(if_modified_since) = headers.get("if-modified-since")
&& validators.not_newer_than(if_modified_since)
{
return Outcome::NotModified;
}
let range = match headers.get("if-range") {
Some(if_range) if !validators.if_range_agrees(if_range) => Range::Whole,
_ => range_of(headers.get("range"), len),
};
match range {
Range::Whole => Outcome::Whole,
Range::Slice { start, end } => Outcome::Partial { start, end },
Range::Unsatisfiable => Outcome::Unsatisfiable,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::time::Duration;
fn header(value: &str) -> String {
value.to_string()
}
fn headers(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect()
}
const EXAMPLE_SECS: i64 = 784_111_777;
fn example_mtime() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(u64::try_from(EXAMPLE_SECS).unwrap())
}
#[test]
fn a_closed_range_is_the_slice_it_names_inclusive_of_both_ends() {
assert_eq!(
range_of(Some(&header("bytes=2-5")), 10),
Range::Slice { start: 2, end: 5 }
);
assert_eq!(
range_of(Some(&header("bytes=0-0")), 10),
Range::Slice { start: 0, end: 0 }
);
}
#[test]
fn an_open_range_runs_to_the_last_byte() {
assert_eq!(
range_of(Some(&header("bytes=9-")), 10),
Range::Slice { start: 9, end: 9 }
);
}
#[test]
fn a_suffix_range_counts_back_from_the_end() {
assert_eq!(
range_of(Some(&header("bytes=-3")), 10),
Range::Slice { start: 7, end: 9 }
);
}
#[test]
fn a_last_byte_past_the_end_is_clamped_to_the_file() {
assert_eq!(
range_of(Some(&header("bytes=5-900")), 10),
Range::Slice { start: 5, end: 9 }
);
assert_eq!(
range_of(Some(&header("bytes=-900")), 10),
Range::Slice { start: 0, end: 9 }
);
}
#[test]
fn a_range_no_part_of_which_exists_is_unsatisfiable() {
for value in ["bytes=999-", "bytes=10-20", "bytes=5-2", "bytes=-0"] {
assert_eq!(
range_of(Some(&header(value)), 10),
Range::Unsatisfiable,
"{value}"
);
}
assert_eq!(
range_of(Some(&header("bytes=0-9")), 0),
Range::Unsatisfiable,
"no range of an empty file exists"
);
}
#[test]
fn a_header_outside_the_single_range_grammar_is_ignored() {
for value in [
"bytes=0-1,4-5",
"bytes=0-1, 4-5",
"bytes=",
"bytes=-",
"bytes=abc-5",
"bytes=0-1-2",
"items=0-1",
"0-1",
"bytes =0-1",
"bytes=0 - 1",
"bytes=+2-5",
"bytes=0-18446744073709551616",
] {
assert_eq!(range_of(Some(&header(value)), 10), Range::Whole, "{value}");
}
assert_eq!(range_of(None, 10), Range::Whole);
}
#[test]
fn the_etag_is_weak_and_carries_both_halves_of_the_metadata() {
let built = Validators::new(13, Some(example_mtime()));
assert_eq!(built.etag(), format!("W/\"13-{EXAMPLE_SECS}\""));
assert_eq!(
Validators::new(14, Some(example_mtime())).etag(),
format!("W/\"14-{EXAMPLE_SECS}\"")
);
assert_ne!(
Validators::new(13, Some(example_mtime() + Duration::from_secs(1))).etag(),
built.etag()
);
assert_eq!(Validators::new(13, None).etag(), "W/\"13\"");
}
#[test]
fn last_modified_is_an_imf_fixdate() {
assert_eq!(
Validators::new(13, Some(example_mtime()))
.last_modified()
.as_deref(),
Some("Sun, 06 Nov 1994 08:49:37 GMT")
);
assert_eq!(Validators::new(13, None).last_modified(), None);
}
#[test]
fn an_http_date_is_read_in_the_three_forms_the_spec_names() {
for value in [
"Sun, 06 Nov 1994 08:49:37 GMT",
"Sun, 06-Nov-94 08:49:37 GMT",
"Sun Nov 6 08:49:37 1994",
] {
assert_eq!(parse_http_date(value), Some(EXAMPLE_SECS), "{value}");
}
for value in [
"Sun, 06 Nov 1994 08:49:37 +0000",
"1994-11-06T08:49:37Z",
"0",
"",
"Sun, 06 Nov 1994",
] {
assert_eq!(parse_http_date(value), None, "{value}");
}
}
#[test]
fn if_none_match_compares_weakly_and_reads_a_list() {
let built = Validators::new(13, Some(example_mtime()));
assert!(built.none_match_agrees("W/\"13-784111777\""));
assert!(
built.none_match_agrees("\"13-784111777\""),
"a strong tag matches a weak one by weak comparison"
);
assert!(built.none_match_agrees("W/\"other\", W/\"13-784111777\""));
assert!(built.none_match_agrees("*"));
assert!(!built.none_match_agrees("W/\"13-784111778\""));
assert!(!built.none_match_agrees("\"13\""));
}
#[test]
fn if_modified_since_matches_the_second_the_file_was_written_in() {
let built = Validators::new(13, Some(example_mtime()));
assert!(built.not_newer_than("Sun, 06 Nov 1994 08:49:37 GMT"));
assert!(
built.not_newer_than("Sun, 06 Nov 1994 08:49:38 GMT"),
"a client holding a copy from a second later"
);
assert!(!built.not_newer_than("Sun, 06 Nov 1994 08:49:36 GMT"));
assert!(!built.not_newer_than("not a date"));
assert!(!Validators::new(13, None).not_newer_than("Sun, 06 Nov 1994 08:49:37 GMT"));
}
#[test]
fn if_range_agrees_only_with_the_files_own_last_modified() {
let built = Validators::new(13, Some(example_mtime()));
assert!(built.if_range_agrees("Sun, 06 Nov 1994 08:49:37 GMT"));
assert!(!built.if_range_agrees("Sun, 06 Nov 1994 08:49:36 GMT"));
assert!(!built.if_range_agrees("W/\"13-784111777\""), "a weak tag");
assert!(!built.if_range_agrees("\"13-784111777\""));
assert!(!built.if_range_agrees("nonsense"));
}
#[test]
fn a_matching_validator_answers_304_in_place_of_a_range() {
let built = Validators::new(10, Some(example_mtime()));
assert_eq!(
decide(
&headers(&[
("if-none-match", "W/\"10-784111777\""),
("range", "bytes=2-5"),
]),
10,
&built
),
Outcome::NotModified
);
assert_eq!(
decide(
&headers(&[
("if-modified-since", "Sun, 06 Nov 1994 08:49:37 GMT"),
("range", "bytes=2-5"),
]),
10,
&built
),
Outcome::NotModified
);
}
#[test]
fn if_none_match_outranks_if_modified_since() {
let built = Validators::new(10, Some(example_mtime()));
assert_eq!(
decide(
&headers(&[
("if-none-match", "W/\"other-tag\""),
("if-modified-since", "Sun, 06 Nov 1994 08:49:37 GMT"),
]),
10,
&built
),
Outcome::Whole,
"the newer validator is the one that answers"
);
}
#[test]
fn a_stale_if_range_sends_the_whole_file() {
let built = Validators::new(10, Some(example_mtime()));
assert_eq!(
decide(
&headers(&[
("if-range", "Sun, 01 Jan 1970 00:00:01 GMT"),
("range", "bytes=2-5")
]),
10,
&built
),
Outcome::Whole
);
assert_eq!(
decide(
&headers(&[("if-range", "W/\"10-784111777\""), ("range", "bytes=2-5")]),
10,
&built
),
Outcome::Whole
);
assert_eq!(
decide(
&headers(&[
("if-range", "Sun, 06 Nov 1994 08:49:37 GMT"),
("range", "bytes=2-5"),
]),
10,
&built
),
Outcome::Partial { start: 2, end: 5 },
"the matching one is the one that does apply it"
);
}
#[test]
fn a_request_with_no_conditional_headers_gets_the_whole_file() {
let built = Validators::new(10, Some(example_mtime()));
assert_eq!(decide(&headers(&[]), 10, &built), Outcome::Whole);
assert_eq!(
decide(&headers(&[("range", "bytes=999-")]), 10, &built),
Outcome::Unsatisfiable
);
assert_eq!(
decide(&headers(&[("range", "bytes=0-1,4-5")]), 10, &built),
Outcome::Whole
);
}
}