aster_forge_utils/
http_validators.rs1use std::time::{SystemTime, UNIX_EPOCH};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
7pub enum HttpValidatorError {
8 #[error("ETag list must contain at least one entity tag")]
10 EmptyEtagList,
11 #[error("invalid HTTP date")]
13 InvalidHttpDate,
14}
15
16pub fn format_http_date(time: SystemTime) -> String {
18 httpdate::fmt_http_date(time)
19}
20
21pub fn parse_http_date(value: &str) -> Result<SystemTime, HttpValidatorError> {
23 httpdate::parse_http_date(value).map_err(|_| HttpValidatorError::InvalidHttpDate)
24}
25
26pub 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
34pub 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
68pub 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}