1use std::borrow::Cow;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use headers::{ETag, Header, IfMatch, IfNoneMatch};
7use http::header::{IF_MATCH, IF_NONE_MATCH};
8use http::{HeaderMap, HeaderValue};
9
10const MAX_ETAG_LIST_ELEMENTS: usize = 128;
11const MAX_HTTP_DATE_EPOCH_SECONDS: u64 = 253_402_300_799;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15pub enum HttpValidatorError {
16 #[error("invalid ETag list")]
18 InvalidEtagList,
19 #[error("invalid HTTP date")]
21 InvalidHttpDate,
22}
23
24#[deprecated(note = "use try_format_http_date to validate the supported HTTP-date range")]
26#[must_use]
27pub fn format_http_date(time: SystemTime) -> String {
28 httpdate::fmt_http_date(time)
29}
30
31pub fn validate_http_date(time: SystemTime) -> Result<(), HttpValidatorError> {
38 let duration = time
39 .duration_since(UNIX_EPOCH)
40 .map_err(|_| HttpValidatorError::InvalidHttpDate)?;
41 if duration.as_secs() > MAX_HTTP_DATE_EPOCH_SECONDS {
42 return Err(HttpValidatorError::InvalidHttpDate);
43 }
44 Ok(())
45}
46
47pub fn try_format_http_date(time: SystemTime) -> Result<String, HttpValidatorError> {
54 validate_http_date(time)?;
55 Ok(httpdate::fmt_http_date(time))
56}
57
58pub fn try_format_entity_tag(value: &str) -> Result<Cow<'_, str>, HttpValidatorError> {
68 let value = value.trim();
69 if value == "*" {
70 return Err(HttpValidatorError::InvalidEtagList);
71 }
72 let rendered = if value.starts_with('"') || value.starts_with("W/\"") {
73 Cow::Borrowed(value)
74 } else {
75 Cow::Owned(format!("\"{value}\""))
76 };
77 rendered
78 .parse::<ETag>()
79 .map_err(|_| HttpValidatorError::InvalidEtagList)?;
80 Ok(rendered)
81}
82
83pub fn parse_http_date(value: &str) -> Result<SystemTime, HttpValidatorError> {
89 httpdate::parse_http_date(value).map_err(|_| HttpValidatorError::InvalidHttpDate)
90}
91
92#[must_use]
94pub fn http_date_epoch_seconds(time: SystemTime) -> i128 {
95 match time.duration_since(UNIX_EPOCH) {
96 Ok(duration) => i128::from(duration.as_secs()),
97 Err(error) => -i128::from(error.duration().as_secs()),
98 }
99}
100
101pub fn if_match_header_matches(
108 raw: &str,
109 resource_exists: bool,
110 current_etag: Option<&str>,
111) -> Result<bool, HttpValidatorError> {
112 match parse_etag_list(raw.as_bytes())? {
113 ParsedEtagList::Any => Ok(resource_exists),
114 ParsedEtagList::Tags(candidates) => strong_candidates_match(
115 &candidates,
116 resource_exists.then_some(current_etag).flatten(),
117 ),
118 }
119}
120
121pub fn if_match_headers_match(
128 headers: &HeaderMap,
129 resource_exists: bool,
130 current_etag: Option<&str>,
131) -> Result<Option<bool>, HttpValidatorError> {
132 let Some(raw) = combined_header_bytes(headers, IF_MATCH) else {
133 return Ok(None);
134 };
135 match parse_etag_list(&raw)? {
136 ParsedEtagList::Any => Ok(Some(resource_exists)),
137 ParsedEtagList::Tags(candidates) => strong_candidates_match(
138 &candidates,
139 resource_exists.then_some(current_etag).flatten(),
140 )
141 .map(Some),
142 }
143}
144
145pub fn if_match_headers_have_strong_tag(
155 headers: &HeaderMap,
156) -> Result<Option<bool>, HttpValidatorError> {
157 let Some(raw) = combined_header_bytes(headers, IF_MATCH) else {
158 return Ok(None);
159 };
160 Ok(Some(match parse_etag_list(&raw)? {
161 ParsedEtagList::Any => false,
162 ParsedEtagList::Tags(candidates) => candidates.iter().any(|candidate| !candidate.weak),
163 }))
164}
165
166fn strong_candidates_match(
167 candidates: &[ParsedEntityTag],
168 current_etag: Option<&str>,
169) -> Result<bool, HttpValidatorError> {
170 if candidates.is_empty() {
171 return Ok(false);
172 }
173 let Some(current_etag) = current_etag else {
174 return Ok(false);
175 };
176 let current = parse_entity_tag(current_etag)?;
177 Ok(candidates
178 .iter()
179 .any(|candidate| IfMatch::from(candidate.tag.clone()).precondition_passes(¤t)))
180}
181
182pub fn if_none_match_header_matches(
189 raw: &str,
190 resource_exists: bool,
191 current_etag: Option<&str>,
192) -> Result<bool, HttpValidatorError> {
193 match parse_etag_list(raw.as_bytes())? {
194 ParsedEtagList::Any => Ok(resource_exists),
195 ParsedEtagList::Tags(candidates) => weak_candidates_match(
196 &candidates,
197 resource_exists.then_some(current_etag).flatten(),
198 ),
199 }
200}
201
202pub fn if_none_match_headers_match(
209 headers: &HeaderMap,
210 resource_exists: bool,
211 current_etag: Option<&str>,
212) -> Result<Option<bool>, HttpValidatorError> {
213 let Some(raw) = combined_header_bytes(headers, IF_NONE_MATCH) else {
214 return Ok(None);
215 };
216 match parse_etag_list(&raw)? {
217 ParsedEtagList::Any => Ok(Some(resource_exists)),
218 ParsedEtagList::Tags(candidates) => weak_candidates_match(
219 &candidates,
220 resource_exists.then_some(current_etag).flatten(),
221 )
222 .map(Some),
223 }
224}
225
226fn weak_candidates_match(
227 candidates: &[ParsedEntityTag],
228 current_etag: Option<&str>,
229) -> Result<bool, HttpValidatorError> {
230 if candidates.is_empty() {
231 return Ok(false);
232 }
233 let Some(current_etag) = current_etag else {
234 return Ok(false);
235 };
236 let current = parse_entity_tag(current_etag)?;
237 Ok(candidates
238 .iter()
239 .any(|candidate| !IfNoneMatch::from(candidate.tag.clone()).precondition_passes(¤t)))
240}
241
242enum ParsedEtagList {
243 Any,
244 Tags(Vec<ParsedEntityTag>),
245}
246
247struct ParsedEntityTag {
248 tag: ETag,
249 weak: bool,
250}
251
252fn parse_etag_list(raw: &[u8]) -> Result<ParsedEtagList, HttpValidatorError> {
253 let trimmed = trim_ows(raw);
254 if trimmed == b"*" {
255 return Ok(ParsedEtagList::Any);
256 }
257 let mut tags = Vec::new();
258 let mut remaining = raw;
259 let mut elements = 0_usize;
260 loop {
261 remaining = trim_start_ows(remaining);
262 while let Some(rest) = remaining.strip_prefix(b",") {
263 elements = elements.saturating_add(1);
264 if elements > MAX_ETAG_LIST_ELEMENTS {
265 return Err(HttpValidatorError::InvalidEtagList);
266 }
267 remaining = trim_start_ows(rest);
268 }
269 if remaining.is_empty() {
270 break;
271 }
272 elements = elements.saturating_add(1);
273 if elements > MAX_ETAG_LIST_ELEMENTS {
274 return Err(HttpValidatorError::InvalidEtagList);
275 }
276 let weak = remaining.starts_with(b"W/");
277 let quoted = if weak {
278 remaining.get(2..)
279 } else {
280 Some(remaining)
281 }
282 .and_then(|value| value.strip_prefix(b"\""))
283 .ok_or(HttpValidatorError::InvalidEtagList)?;
284 let closing = quoted
285 .iter()
286 .position(|byte| *byte == b'"')
287 .ok_or(HttpValidatorError::InvalidEtagList)?;
288 let consumed = closing + 2 + usize::from(weak) * 2;
289 let candidate = remaining
290 .get(..consumed)
291 .ok_or(HttpValidatorError::InvalidEtagList)?;
292 tags.push(ParsedEntityTag {
293 tag: parse_entity_tag_bytes(candidate)?,
294 weak,
295 });
296 remaining = remaining
297 .get(consumed..)
298 .ok_or(HttpValidatorError::InvalidEtagList)?;
299 let trimmed = trim_start_ows(remaining);
300 if trimmed.is_empty() {
301 break;
302 }
303 remaining = trimmed
304 .strip_prefix(b",")
305 .ok_or(HttpValidatorError::InvalidEtagList)?;
306 }
307 Ok(ParsedEtagList::Tags(tags))
308}
309
310fn parse_entity_tag(value: &str) -> Result<ETag, HttpValidatorError> {
311 let rendered = try_format_entity_tag(value)?;
312 rendered
313 .parse()
314 .map_err(|_| HttpValidatorError::InvalidEtagList)
315}
316
317fn parse_entity_tag_bytes(value: &[u8]) -> Result<ETag, HttpValidatorError> {
318 let value = HeaderValue::from_bytes(value).map_err(|_| HttpValidatorError::InvalidEtagList)?;
319 ETag::decode(&mut std::iter::once(&value)).map_err(|_| HttpValidatorError::InvalidEtagList)
320}
321
322fn combined_header_bytes(headers: &HeaderMap, name: http::header::HeaderName) -> Option<Vec<u8>> {
323 let mut combined = Vec::new();
324 let mut present = false;
325 for value in &headers.get_all(name) {
326 if present {
327 combined.push(b',');
328 }
329 present = true;
330 combined.extend_from_slice(value.as_bytes());
331 }
332 present.then_some(combined)
333}
334
335fn trim_ows(value: &[u8]) -> &[u8] {
336 let value = trim_start_ows(value);
337 let end = value
338 .iter()
339 .rposition(|byte| !matches!(byte, b' ' | b'\t'))
340 .map_or(0, |index| index + 1);
341 &value[..end]
342}
343
344fn trim_start_ows(value: &[u8]) -> &[u8] {
345 let start = value
346 .iter()
347 .position(|byte| !matches!(byte, b' ' | b'\t'))
348 .unwrap_or(value.len());
349 &value[start..]
350}
351
352#[cfg(test)]
353mod tests {
354 use std::time::{Duration, UNIX_EPOCH};
355
356 use super::{
357 HttpValidatorError, MAX_ETAG_LIST_ELEMENTS, MAX_HTTP_DATE_EPOCH_SECONDS,
358 http_date_epoch_seconds, if_match_header_matches, if_match_headers_have_strong_tag,
359 if_match_headers_match, if_none_match_header_matches, if_none_match_headers_match,
360 parse_http_date, try_format_entity_tag, try_format_http_date, validate_http_date,
361 };
362 use http::header::{IF_MATCH, IF_NONE_MATCH};
363 use http::{HeaderMap, HeaderValue};
364 use std::borrow::Cow;
365
366 #[test]
367 fn if_none_match_uses_weak_comparison() {
368 assert_eq!(
369 if_none_match_header_matches(r#"W/"etag-1", "etag-2""#, true, Some(r#""etag-1""#)),
370 Ok(true)
371 );
372 }
373
374 #[test]
375 fn if_match_requires_strong_comparison() {
376 assert_eq!(
377 if_match_header_matches(r#"W/"etag-1""#, true, Some(r#""etag-1""#)),
378 Ok(false)
379 );
380 assert_eq!(
381 if_match_header_matches(r#""etag-1""#, true, Some(r#""etag-1""#)),
382 Ok(true)
383 );
384 assert_eq!(
385 if_match_header_matches(r#""etag-1""#, true, Some(r#"W/"etag-1""#)),
386 Ok(false)
387 );
388 }
389
390 #[test]
391 fn strong_if_match_detection_distinguishes_tags_wildcard_and_absence() {
392 let mut headers = HeaderMap::new();
393 assert_eq!(if_match_headers_have_strong_tag(&headers), Ok(None));
394
395 for (raw, expected) in [
396 ("*", false),
397 (" , ", false),
398 ("W/\"weak\"", false),
399 ("W/\"weak\", \"strong\"", true),
400 ("\"strong\"", true),
401 ] {
402 headers.insert(IF_MATCH, HeaderValue::from_str(raw).expect("If-Match"));
403 assert_eq!(
404 if_match_headers_have_strong_tag(&headers),
405 Ok(Some(expected))
406 );
407 }
408
409 headers.insert(IF_MATCH, HeaderValue::from_static("bare-etag"));
410 assert_eq!(
411 if_match_headers_have_strong_tag(&headers),
412 Err(HttpValidatorError::InvalidEtagList)
413 );
414 }
415
416 #[test]
417 fn opaque_backend_etags_that_start_with_weak_marker_text_are_quoted() {
418 assert_eq!(
419 if_match_header_matches(r#""W/backend-value""#, true, Some("W/backend-value")),
420 Ok(true)
421 );
422 assert_eq!(
423 if_none_match_header_matches(r#""W/backend-value""#, true, Some("W/backend-value")),
424 Ok(true)
425 );
426 }
427
428 #[test]
429 fn wildcard_respects_resource_existence() {
430 assert_eq!(if_match_header_matches("*", true, None), Ok(true));
431 assert_eq!(if_match_header_matches("*", false, None), Ok(false));
432 assert_eq!(if_none_match_header_matches("*", true, None), Ok(true));
433 assert_eq!(if_none_match_header_matches("*", false, None), Ok(false));
434 }
435
436 #[test]
437 fn empty_etag_lists_use_zero_member_rfc_semantics() {
438 assert_eq!(
439 if_none_match_header_matches(" , ", true, Some("etag")),
440 Ok(false)
441 );
442 assert_eq!(
443 if_match_header_matches(" , ", true, Some("etag")),
444 Ok(false)
445 );
446
447 let mut headers = HeaderMap::new();
448 headers.insert(IF_MATCH, HeaderValue::from_static(""));
449 assert_eq!(
450 if_match_headers_match(&headers, true, Some("etag")),
451 Ok(Some(false))
452 );
453
454 let mut headers = HeaderMap::new();
455 headers.insert(IF_NONE_MATCH, HeaderValue::from_static(""));
456 assert_eq!(
457 if_none_match_headers_match(&headers, true, Some("etag")),
458 Ok(Some(false))
459 );
460 }
461
462 #[test]
463 fn explicit_etag_lists_do_not_match_without_a_current_validator() {
464 assert_eq!(if_match_header_matches("\"etag-1\"", true, None), Ok(false));
465 assert_eq!(
466 if_none_match_header_matches("\"etag-1\"", true, None),
467 Ok(false)
468 );
469 }
470
471 #[test]
472 fn entity_tag_formatting_borrows_rendered_values_and_quotes_opaque_values() {
473 assert_eq!(
474 try_format_entity_tag(" \"strong\" "),
475 Ok(Cow::Borrowed("\"strong\""))
476 );
477 assert_eq!(
478 try_format_entity_tag("W/\"weak\""),
479 Ok(Cow::Borrowed("W/\"weak\""))
480 );
481 assert_eq!(
482 try_format_entity_tag(" opaque "),
483 Ok(Cow::Owned::<str>("\"opaque\"".to_owned()))
484 );
485 }
486
487 #[test]
488 fn entity_tag_formatting_rejects_wildcards_and_malformed_rendered_values() {
489 for value in [
490 "*",
491 "\"unterminated",
492 "W/\"unterminated",
493 "\"tag\" trailing",
494 "bad\netag",
495 ] {
496 assert_eq!(
497 try_format_entity_tag(value),
498 Err(HttpValidatorError::InvalidEtagList),
499 "{value:?}"
500 );
501 }
502 }
503
504 #[test]
505 fn conditions_that_do_not_need_a_current_tag_ignore_invalid_metadata() {
506 for raw in ["", " , "] {
507 assert_eq!(
508 if_match_header_matches(raw, true, Some("bad\netag")),
509 Ok(false)
510 );
511 assert_eq!(
512 if_none_match_header_matches(raw, true, Some("bad\netag")),
513 Ok(false)
514 );
515 }
516 assert_eq!(
517 if_match_header_matches("\"candidate\"", false, Some("bad\netag")),
518 Ok(false)
519 );
520 assert_eq!(
521 if_none_match_header_matches("\"candidate\"", false, Some("bad\netag")),
522 Ok(false)
523 );
524 }
525
526 #[test]
527 fn malformed_etag_lists_are_invalid() {
528 for raw in [r"etag-1", r#"*, "etag-1""#, r#""unterminated"#] {
529 assert_eq!(
530 if_none_match_header_matches(raw, true, Some(r#""etag-1""#)),
531 Err(HttpValidatorError::InvalidEtagList),
532 "{raw:?}"
533 );
534 }
535 }
536
537 #[test]
538 fn recipient_list_parsing_ignores_empty_members_and_preserves_opaque_commas() {
539 for raw in [
540 r#", "etag-1""#,
541 r#""etag-1","#,
542 r#", , "etag-1", ,"#,
543 r#""opaque,comma", "etag-1""#,
544 ] {
545 assert_eq!(
546 if_none_match_header_matches(raw, true, Some(r#""etag-1""#)),
547 Ok(true),
548 "{raw:?}"
549 );
550 }
551 assert_eq!(
552 if_match_header_matches(
553 r#""opaque,comma", "other""#,
554 true,
555 Some(r#""opaque,comma""#),
556 ),
557 Ok(true)
558 );
559 }
560
561 #[test]
562 fn repeated_field_lines_are_combined_as_one_rfc_list() {
563 let mut headers = HeaderMap::new();
564 headers.append(IF_MATCH, HeaderValue::from_static("\"other\""));
565 headers.append(IF_MATCH, HeaderValue::from_static("\"etag-1\""));
566 assert_eq!(
567 if_match_headers_match(&headers, true, Some("etag-1")),
568 Ok(Some(true))
569 );
570
571 let mut headers = HeaderMap::new();
572 headers.append(IF_NONE_MATCH, HeaderValue::from_static("\"other\""));
573 headers.append(IF_NONE_MATCH, HeaderValue::from_static("W/\"etag-1\""));
574 assert_eq!(
575 if_none_match_headers_match(&headers, true, Some("etag-1")),
576 Ok(Some(true))
577 );
578 }
579
580 #[test]
581 fn obs_text_is_valid_inside_an_opaque_tag() {
582 let mut headers = HeaderMap::new();
583 headers.insert(
584 IF_MATCH,
585 HeaderValue::from_bytes(&[b'"', 0xff, b'"']).expect("obs-text header"),
586 );
587 assert_eq!(
588 if_match_headers_match(&headers, true, Some("etag-1")),
589 Ok(Some(false))
590 );
591 }
592
593 #[test]
594 fn reasonable_empty_members_are_bounded() {
595 let accepted = ",".repeat(MAX_ETAG_LIST_ELEMENTS - 1) + "\"etag-1\"";
596 assert_eq!(
597 if_match_header_matches(&accepted, true, Some("etag-1")),
598 Ok(true)
599 );
600
601 let rejected = ",".repeat(MAX_ETAG_LIST_ELEMENTS) + "\"etag-1\"";
602 assert_eq!(
603 if_match_header_matches(&rejected, true, Some("etag-1")),
604 Err(HttpValidatorError::InvalidEtagList)
605 );
606
607 let empty_only = ",".repeat(MAX_ETAG_LIST_ELEMENTS + 1);
608 assert_eq!(
609 if_match_header_matches(&empty_only, true, Some("etag-1")),
610 Err(HttpValidatorError::InvalidEtagList)
611 );
612 }
613
614 #[test]
615 #[expect(
616 deprecated,
617 reason = "The compatibility assertion verifies the deprecated formatter on a validated time."
618 )]
619 fn http_dates_round_trip_and_reject_invalid_values() {
620 let time = UNIX_EPOCH + Duration::from_secs(784_111_777);
621 let formatted = super::format_http_date(time);
622
623 assert_eq!(formatted, "Sun, 06 Nov 1994 08:49:37 GMT");
624 assert_eq!(parse_http_date(&formatted), Ok(time));
625 assert_eq!(
626 parse_http_date("not a date"),
627 Err(HttpValidatorError::InvalidHttpDate)
628 );
629 assert_eq!(try_format_http_date(time), Ok(formatted));
630 assert_eq!(validate_http_date(time), Ok(()));
631 assert_eq!(
632 try_format_http_date(UNIX_EPOCH - Duration::from_secs(1)),
633 Err(HttpValidatorError::InvalidHttpDate)
634 );
635 assert_eq!(
636 validate_http_date(UNIX_EPOCH - Duration::from_secs(1)),
637 Err(HttpValidatorError::InvalidHttpDate)
638 );
639 assert_eq!(
640 try_format_http_date(UNIX_EPOCH + Duration::from_secs(MAX_HTTP_DATE_EPOCH_SECONDS + 1),),
641 Err(HttpValidatorError::InvalidHttpDate)
642 );
643 assert_eq!(
644 validate_http_date(UNIX_EPOCH + Duration::from_secs(MAX_HTTP_DATE_EPOCH_SECONDS + 1),),
645 Err(HttpValidatorError::InvalidHttpDate)
646 );
647 }
648
649 #[test]
650 fn epoch_seconds_preserve_pre_epoch_ordering() {
651 assert_eq!(http_date_epoch_seconds(UNIX_EPOCH), 0);
652 assert_eq!(
653 http_date_epoch_seconds(UNIX_EPOCH + Duration::from_secs(2)),
654 2
655 );
656 assert_eq!(
657 http_date_epoch_seconds(UNIX_EPOCH - Duration::from_secs(2)),
658 -2
659 );
660 }
661}