Skip to main content

aster_forge_utils/
http_validators.rs

1//! Transport-neutral HTTP conditional request helpers.
2
3use std::time::{SystemTime, UNIX_EPOCH};
4
5/// Errors produced while parsing HTTP validators.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
7pub enum HttpValidatorError {
8    /// A comma-separated ETag list did not contain an entity tag.
9    #[error("ETag list must contain at least one entity tag")]
10    EmptyEtagList,
11    /// A value was not a valid HTTP date.
12    #[error("invalid HTTP date")]
13    InvalidHttpDate,
14}
15
16/// Formats a system time as an IMF-fixdate HTTP date.
17pub fn format_http_date(time: SystemTime) -> String {
18    httpdate::fmt_http_date(time)
19}
20
21/// Parses an HTTP date into system time.
22pub fn parse_http_date(value: &str) -> Result<SystemTime, HttpValidatorError> {
23    httpdate::parse_http_date(value).map_err(|_| HttpValidatorError::InvalidHttpDate)
24}
25
26/// Returns whole seconds relative to the Unix epoch, preserving pre-epoch ordering.
27pub fn http_date_epoch_seconds(time: SystemTime) -> i128 {
28    match time.duration_since(UNIX_EPOCH) {
29        Ok(duration) => i128::from(duration.as_secs()),
30        Err(error) => -i128::from(error.duration().as_secs()),
31    }
32}
33
34/// Applies the strong comparison required by `If-Match`.
35pub fn if_match_header_matches(
36    raw: &str,
37    resource_exists: bool,
38    current_etag: Option<&str>,
39) -> Result<bool, HttpValidatorError> {
40    let raw = raw.trim();
41    if raw == "*" {
42        return Ok(resource_exists);
43    }
44    let Some(current_etag) = current_etag else {
45        return Ok(false);
46    };
47    let mut saw_tag = false;
48    for candidate in raw
49        .split(',')
50        .map(str::trim)
51        .filter(|value| !value.is_empty())
52    {
53        saw_tag = true;
54        if is_weak_etag(candidate) {
55            continue;
56        }
57        if strong_etag_matches(candidate, current_etag) {
58            return Ok(true);
59        }
60    }
61    if saw_tag {
62        Ok(false)
63    } else {
64        Err(HttpValidatorError::EmptyEtagList)
65    }
66}
67
68/// Applies the weak comparison required by `If-None-Match`.
69pub fn if_none_match_header_matches(
70    raw: &str,
71    resource_exists: bool,
72    current_etag: Option<&str>,
73) -> Result<bool, HttpValidatorError> {
74    let raw = raw.trim();
75    if raw == "*" {
76        return Ok(resource_exists);
77    }
78    let Some(current_etag) = current_etag else {
79        return Ok(false);
80    };
81    let mut saw_tag = false;
82    for candidate in raw
83        .split(',')
84        .map(str::trim)
85        .filter(|value| !value.is_empty())
86    {
87        saw_tag = true;
88        if etag_matches(candidate, current_etag) {
89            return Ok(true);
90        }
91    }
92    if saw_tag {
93        Ok(false)
94    } else {
95        Err(HttpValidatorError::EmptyEtagList)
96    }
97}
98
99fn etag_matches(header_value: &str, current_etag: &str) -> bool {
100    let header_value = strip_weak_etag_prefix(header_value.trim());
101    let current = strip_weak_etag_prefix(current_etag.trim());
102    strip_etag_quotes(header_value) == strip_etag_quotes(current)
103}
104
105fn strong_etag_matches(candidate: &str, current_etag: &str) -> bool {
106    if is_weak_etag(current_etag) {
107        return false;
108    }
109    strip_etag_quotes(candidate.trim()) == strip_etag_quotes(current_etag.trim())
110}
111
112fn is_weak_etag(value: &str) -> bool {
113    value
114        .trim()
115        .get(..2)
116        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/"))
117}
118
119fn strip_weak_etag_prefix(value: &str) -> &str {
120    value
121        .strip_prefix("W/")
122        .or_else(|| value.strip_prefix("w/"))
123        .unwrap_or(value)
124}
125
126fn strip_etag_quotes(value: &str) -> &str {
127    value
128        .strip_prefix('"')
129        .and_then(|value| value.strip_suffix('"'))
130        .unwrap_or(value)
131}
132
133#[cfg(test)]
134mod tests {
135    use std::time::{Duration, UNIX_EPOCH};
136
137    use super::{
138        HttpValidatorError, format_http_date, http_date_epoch_seconds, if_match_header_matches,
139        if_none_match_header_matches, parse_http_date,
140    };
141
142    #[test]
143    fn if_none_match_uses_weak_comparison() {
144        assert_eq!(
145            if_none_match_header_matches(r#"W/"etag-1", "etag-2""#, true, Some(r#""etag-1""#)),
146            Ok(true)
147        );
148    }
149
150    #[test]
151    fn if_match_requires_strong_comparison() {
152        assert_eq!(
153            if_match_header_matches(r#"W/"etag-1""#, true, Some(r#""etag-1""#)),
154            Ok(false)
155        );
156        assert_eq!(
157            if_match_header_matches(r#""etag-1""#, true, Some(r#""etag-1""#)),
158            Ok(true)
159        );
160        assert_eq!(
161            if_match_header_matches(r#""etag-1""#, true, Some(r#"W/"etag-1""#)),
162            Ok(false)
163        );
164    }
165
166    #[test]
167    fn wildcard_respects_resource_existence() {
168        assert_eq!(if_match_header_matches("*", true, None), Ok(true));
169        assert_eq!(if_match_header_matches("*", false, None), Ok(false));
170        assert_eq!(if_none_match_header_matches("*", true, None), Ok(true));
171        assert_eq!(if_none_match_header_matches("*", false, None), Ok(false));
172    }
173
174    #[test]
175    fn empty_etag_lists_are_invalid() {
176        assert_eq!(
177            if_none_match_header_matches(" , ", true, Some("etag")),
178            Err(HttpValidatorError::EmptyEtagList)
179        );
180        assert_eq!(
181            if_match_header_matches(" , ", true, Some("etag")),
182            Err(HttpValidatorError::EmptyEtagList)
183        );
184    }
185
186    #[test]
187    fn http_dates_round_trip_and_reject_invalid_values() {
188        let time = UNIX_EPOCH + Duration::from_secs(784_111_777);
189        let formatted = format_http_date(time);
190
191        assert_eq!(formatted, "Sun, 06 Nov 1994 08:49:37 GMT");
192        assert_eq!(parse_http_date(&formatted), Ok(time));
193        assert_eq!(
194            parse_http_date("not a date"),
195            Err(HttpValidatorError::InvalidHttpDate)
196        );
197    }
198
199    #[test]
200    fn epoch_seconds_preserve_pre_epoch_ordering() {
201        assert_eq!(http_date_epoch_seconds(UNIX_EPOCH), 0);
202        assert_eq!(
203            http_date_epoch_seconds(UNIX_EPOCH + Duration::from_secs(2)),
204            2
205        );
206        assert_eq!(
207            http_date_epoch_seconds(UNIX_EPOCH - Duration::from_secs(2)),
208            -2
209        );
210    }
211}