use std::ops::Range;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::conditional::ConditionalHeaders;
use crate::error::StorageError;
use crate::range::ByteRange;
#[derive(Debug, Clone, Copy)]
pub struct ObjectState<'a> {
pub etag: &'a str,
pub last_modified: SystemTime,
}
#[must_use]
pub fn unix_seconds(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.ok()
.map_or(0, |duration| duration.as_secs())
}
#[must_use]
pub fn unix_seconds_i64(time: SystemTime) -> i64 {
i64::try_from(unix_seconds(time)).unwrap_or(i64::MAX)
}
pub fn parse_http_date_or_err(value: &str) -> Result<SystemTime, StorageError> {
httpdate::parse_http_date(value).map_err(|error| StorageError::Other {
source: Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid HTTP-date '{value}': {error}"),
)),
})
}
#[must_use]
pub fn matches_if_match(current_etag: &str, if_match_value: &str) -> bool {
if if_match_value.trim() == "*" {
return true;
}
if_match_value
.split(',')
.map(str::trim)
.any(|tag| tag == current_etag)
}
#[must_use]
pub fn matches_if_none_match(current_etag: Option<&str>, if_none_match_value: &str) -> bool {
let Some(current) = current_etag else {
return false;
};
if if_none_match_value.trim() == "*" {
return true;
}
if_none_match_value.split(',').map(str::trim).any(|tag| {
let tag = tag.trim_start_matches("W/");
let current = current.trim_start_matches("W/");
tag == current
})
}
pub fn evaluate_write_preconditions(
state: Option<ObjectState<'_>>,
conditionals: &ConditionalHeaders,
) -> Result<(), StorageError> {
if let Some(if_match) = &conditionals.if_match
&& !state.is_some_and(|object| matches_if_match(object.etag, if_match))
{
return Err(StorageError::PreconditionFailed);
}
if let Some(if_none_match) = &conditionals.if_none_match
&& matches_if_none_match(state.map(|object| object.etag), if_none_match)
{
return Err(StorageError::PreconditionFailed);
}
Ok(())
}
pub fn evaluate_read_preconditions(
state: ObjectState<'_>,
conditionals: &ConditionalHeaders,
) -> Result<(), StorageError> {
if let Some(if_match) = &conditionals.if_match
&& !matches_if_match(state.etag, if_match)
{
return Err(StorageError::PreconditionFailed);
}
if let Some(if_unmodified_since) = &conditionals.if_unmodified_since {
let threshold = parse_http_date_or_err(if_unmodified_since)?;
if unix_seconds(state.last_modified) > unix_seconds(threshold) {
return Err(StorageError::PreconditionFailed);
}
}
if let Some(if_none_match) = &conditionals.if_none_match
&& matches_if_none_match(Some(state.etag), if_none_match)
{
return Err(StorageError::NotModified);
}
if let Some(if_modified_since) = &conditionals.if_modified_since {
let threshold = parse_http_date_or_err(if_modified_since)?;
if unix_seconds(state.last_modified) <= unix_seconds(threshold) {
return Err(StorageError::NotModified);
}
}
Ok(())
}
pub fn resolve_range(
total_size: u64,
range: Option<&ByteRange>,
) -> Result<Option<(Range<u64>, String)>, StorageError> {
let Some(byte_range) = range else {
return Ok(None);
};
let exclusive =
byte_range
.to_exclusive_range(total_size)
.ok_or(StorageError::RangeNotSatisfiable {
complete_length: total_size,
})?;
let content_range = format!(
"bytes {}-{}/{}",
exclusive.start,
exclusive.end - 1,
total_size
);
Ok(Some((exclusive, content_range)))
}
#[cfg(test)]
mod tests {
use super::{
ObjectState, evaluate_read_preconditions, evaluate_write_preconditions, matches_if_match,
matches_if_none_match, parse_http_date_or_err, resolve_range,
};
use crate::conditional::ConditionalHeaders;
use crate::error::StorageError;
use crate::range::ByteRange;
use std::time::{Duration, UNIX_EPOCH};
const ETAG: &str = "\"abc\"";
fn state() -> ObjectState<'static> {
ObjectState {
etag: ETAG,
last_modified: UNIX_EPOCH + Duration::from_secs(1_000),
}
}
fn http_date(secs: u64) -> String {
httpdate::fmt_http_date(UNIX_EPOCH + Duration::from_secs(secs))
}
#[test]
fn if_match_accepts_star_and_list_members_but_not_weak_tags() {
assert!(matches_if_match(ETAG, "*"));
assert!(matches_if_match(ETAG, "\"other\", \"abc\""));
assert!(!matches_if_match(ETAG, "\"other\""));
assert!(!matches_if_match(ETAG, "W/\"abc\""));
}
#[test]
fn if_none_match_star_is_false_for_a_missing_object() {
assert!(!matches_if_none_match(None, "*"));
assert!(matches_if_none_match(Some(ETAG), "*"));
}
#[test]
fn if_none_match_compares_weakly() {
assert!(matches_if_none_match(Some(ETAG), "W/\"abc\""));
}
#[test]
fn write_preconditions_ignore_both_date_headers() {
let conditionals = ConditionalHeaders {
if_unmodified_since: Some(http_date(0)),
if_modified_since: Some("not-a-date".into()),
..ConditionalHeaders::default()
};
assert!(evaluate_write_preconditions(Some(state()), &conditionals).is_ok());
}
#[test]
fn write_if_match_fails_when_no_object_exists() {
let conditionals = ConditionalHeaders {
if_match: Some("*".into()),
..ConditionalHeaders::default()
};
assert!(matches!(
evaluate_write_preconditions(None, &conditionals),
Err(StorageError::PreconditionFailed)
));
}
#[test]
fn read_if_match_is_evaluated_before_if_none_match() {
let conditionals = ConditionalHeaders {
if_match: Some("\"stale\"".into()),
if_none_match: Some(ETAG.into()),
..ConditionalHeaders::default()
};
assert!(matches!(
evaluate_read_preconditions(state(), &conditionals),
Err(StorageError::PreconditionFailed)
));
}
#[test]
fn read_if_modified_since_at_the_mtime_is_not_modified() {
let conditionals = ConditionalHeaders {
if_modified_since: Some(http_date(1_000)),
..ConditionalHeaders::default()
};
assert!(matches!(
evaluate_read_preconditions(state(), &conditionals),
Err(StorageError::NotModified)
));
}
#[test]
fn read_rejects_a_malformed_date() {
let conditionals = ConditionalHeaders {
if_modified_since: Some("not-a-date".into()),
..ConditionalHeaders::default()
};
assert!(matches!(
evaluate_read_preconditions(state(), &conditionals),
Err(StorageError::Other { .. })
));
assert!(parse_http_date_or_err("not-a-date").is_err());
}
#[test]
fn resolve_range_reports_an_inclusive_content_range() {
let (exclusive, content_range) = resolve_range(
100,
Some(&ByteRange::FromStart {
first: 10,
last: 19,
}),
)
.expect("satisfiable")
.expect("a range was requested");
assert_eq!(exclusive, 10..20);
assert_eq!(content_range, "bytes 10-19/100");
}
#[test]
fn resolve_range_reports_the_complete_length_when_unsatisfiable() {
assert!(matches!(
resolve_range(
100,
Some(&ByteRange::FromStart {
first: 200,
last: 300
})
),
Err(StorageError::RangeNotSatisfiable {
complete_length: 100
})
));
}
#[test]
fn resolve_range_without_a_range_serves_the_whole_body() {
assert!(resolve_range(100, None).expect("ok").is_none());
}
}