Skip to content

Commit e4f012a

Browse files
feat: add If-Range header support
Add parsing and evaluation of the If-Range header (RFC 9110 Section 13.1.5). When a client sends If-Range alongside Range, the server must check whether the validator (Last-Modified date or ETag) still matches the current representation. If it does not match, the Range header must be ignored and the full representation served. The IfRange type supports both date and entity-tag validators, with strong ETag comparison per RFC 9110 Section 8.8.3.2. An axum extractor is provided behind the `axum` feature flag, returning Ok(None) for missing or unparseable If-Range headers.
1 parent 92c1593 commit e4f012a

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

src/headers/if_range.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#[cfg(feature = "axum")]
2+
use std::convert::Infallible;
3+
use std::str::FromStr;
4+
5+
use http::HeaderValue;
6+
7+
use crate::headers::range::HttpRange;
8+
9+
/// A typed HTTP `If-Range` header.
10+
///
11+
/// Per [RFC 9110 Section 13.1.5], `If-Range` can contain either an HTTP-date
12+
/// or an entity-tag. When present alongside a `Range` header, the server must
13+
/// evaluate the validator against the current representation:
14+
///
15+
/// - If the validator **matches**, the `Range` is honored (206 Partial Content).
16+
/// - If the validator **does not match**, the `Range` is ignored and the full
17+
/// representation is served (200 OK).
18+
///
19+
/// [RFC 9110 Section 13.1.5]: https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5
20+
#[derive(Debug, Clone, PartialEq, Eq)]
21+
pub enum IfRange {
22+
/// An HTTP-date validator (the raw header value, to be compared with `Last-Modified`).
23+
Date(HeaderValue),
24+
/// An entity-tag validator (the raw header value, to be compared with `ETag`).
25+
ETag(HeaderValue),
26+
}
27+
28+
impl IfRange {
29+
/// Evaluates the `If-Range` condition and returns the [`HttpRange`] only if
30+
/// the condition holds.
31+
///
32+
/// - `range`: the parsed `Range` header value.
33+
/// - `last_modified`: the current `Last-Modified` header of the representation.
34+
/// - `etag`: the current `ETag` header of the representation.
35+
///
36+
/// Returns `Some(range)` if the validator matches (the range should be
37+
/// honored), or `None` if it does not (the full representation should be
38+
/// served).
39+
///
40+
/// Per [RFC 9110 Section 13.1.5], the comparison uses the raw header values:
41+
/// - For dates, the `If-Range` value must be an **exact byte-for-byte match**
42+
/// of the `Last-Modified` header value.
43+
/// - For entity-tags, the `If-Range` value must be a **strong comparison**
44+
/// match against the `ETag` header value. Weak entity-tags never match.
45+
///
46+
/// [RFC 9110 Section 13.1.5]: https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5
47+
pub fn evaluate(
48+
&self,
49+
range: HttpRange,
50+
last_modified: Option<&HeaderValue>,
51+
etag: Option<&HeaderValue>,
52+
) -> Option<HttpRange> {
53+
let matches = match self {
54+
IfRange::Date(date) => last_modified.is_some_and(|lm| lm == date),
55+
IfRange::ETag(tag) => etag.is_some_and(|et| strong_etag_eq(tag, et)),
56+
};
57+
58+
if matches { Some(range) } else { None }
59+
}
60+
}
61+
62+
/// Performs a strong comparison of two entity-tags.
63+
///
64+
/// Per [RFC 9110 Section 8.8.3.2], two entity-tags are strongly equivalent if
65+
/// both are **not** weak and their opaque-tags match character by character.
66+
///
67+
/// [RFC 9110 Section 8.8.3.2]: https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3.2
68+
fn strong_etag_eq(a: &HeaderValue, b: &HeaderValue) -> bool {
69+
let a = a.as_bytes();
70+
let b = b.as_bytes();
71+
72+
// Weak tags (W/"...") never match in a strong comparison
73+
if a.starts_with(b"W/") || b.starts_with(b"W/") {
74+
return false;
75+
}
76+
77+
a == b
78+
}
79+
80+
impl FromStr for IfRange {
81+
type Err = InvalidIfRange;
82+
83+
fn from_str(s: &str) -> Result<Self, Self::Err> {
84+
let s = s.trim();
85+
if s.is_empty() {
86+
return Err(InvalidIfRange);
87+
}
88+
89+
let value = HeaderValue::from_str(s).map_err(|_| InvalidIfRange)?;
90+
91+
// Per RFC 9110 Section 13.1.5, the field value is either an entity-tag
92+
// or an HTTP-date. Entity-tags start with `"` or `W/"`.
93+
if s.starts_with('"') || s.starts_with("W/\"") {
94+
Ok(IfRange::ETag(value))
95+
} else {
96+
Ok(IfRange::Date(value))
97+
}
98+
}
99+
}
100+
101+
impl TryFrom<&HeaderValue> for IfRange {
102+
type Error = InvalidIfRange;
103+
104+
fn try_from(value: &HeaderValue) -> Result<Self, Self::Error> {
105+
value.to_str().map_err(|_| InvalidIfRange)?.parse::<Self>()
106+
}
107+
}
108+
109+
/// An error returned when parsing an `If-Range` header fails.
110+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
111+
#[error("Invalid If-Range header")]
112+
pub struct InvalidIfRange;
113+
114+
#[cfg(feature = "axum")]
115+
impl<S> axum_core::extract::OptionalFromRequestParts<S> for IfRange
116+
where
117+
S: Send + Sync,
118+
{
119+
type Rejection = Infallible;
120+
121+
async fn from_request_parts(
122+
parts: &mut http::request::Parts,
123+
_state: &S,
124+
) -> Result<Option<Self>, Self::Rejection> {
125+
let if_range = parts
126+
.headers
127+
.get(http::header::IF_RANGE)
128+
.and_then(|v| IfRange::try_from(v).ok());
129+
Ok(if_range)
130+
}
131+
}

src/headers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::{
55
};
66

77
pub mod content_range;
8+
pub mod if_range;
89
pub mod range;
910
#[cfg(test)]
1011
mod tests;

src/headers/tests.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,3 +599,137 @@ mod serve_file {
599599
assert!(result.is_err());
600600
}
601601
}
602+
603+
#[cfg(test)]
604+
mod if_range {
605+
use http::HeaderValue;
606+
607+
use crate::headers::{if_range::IfRange, range::HttpRange};
608+
609+
#[test]
610+
fn parse_date() {
611+
let ir: IfRange = "Sun, 05 Apr 2026 04:49:21 GMT".parse().unwrap();
612+
assert!(matches!(ir, IfRange::Date(_)));
613+
}
614+
615+
#[test]
616+
fn parse_strong_etag() {
617+
let ir: IfRange = "\"abc123\"".parse().unwrap();
618+
assert!(matches!(ir, IfRange::ETag(_)));
619+
}
620+
621+
#[test]
622+
fn parse_weak_etag() {
623+
let ir: IfRange = "W/\"abc123\"".parse().unwrap();
624+
assert!(matches!(ir, IfRange::ETag(_)));
625+
}
626+
627+
#[test]
628+
fn empty_rejected() {
629+
assert!("".parse::<IfRange>().is_err());
630+
}
631+
632+
#[test]
633+
fn whitespace_only_rejected() {
634+
assert!(" ".parse::<IfRange>().is_err());
635+
}
636+
637+
#[test]
638+
fn date_matches_last_modified() {
639+
let ir: IfRange = "Sun, 05 Apr 2026 04:49:21 GMT".parse().unwrap();
640+
let lm = HeaderValue::from_static("Sun, 05 Apr 2026 04:49:21 GMT");
641+
let range = HttpRange::StartingPoint(0);
642+
643+
assert_eq!(ir.evaluate(range, Some(&lm), None), Some(range));
644+
}
645+
646+
#[test]
647+
fn date_does_not_match_different_last_modified() {
648+
let ir: IfRange = "Mon, 01 Jan 2024 00:00:00 GMT".parse().unwrap();
649+
let lm = HeaderValue::from_static("Sun, 05 Apr 2026 04:49:21 GMT");
650+
let range = HttpRange::StartingPoint(0);
651+
652+
assert_eq!(ir.evaluate(range, Some(&lm), None), None);
653+
}
654+
655+
#[test]
656+
fn date_does_not_match_missing_last_modified() {
657+
let ir: IfRange = "Sun, 05 Apr 2026 04:49:21 GMT".parse().unwrap();
658+
let range = HttpRange::StartingPoint(0);
659+
660+
assert_eq!(ir.evaluate(range, None, None), None);
661+
}
662+
663+
#[test]
664+
fn strong_etag_matches() {
665+
let ir: IfRange = "\"abc123\"".parse().unwrap();
666+
let etag = HeaderValue::from_static("\"abc123\"");
667+
let range = HttpRange::StartingPoint(0);
668+
669+
assert_eq!(ir.evaluate(range, None, Some(&etag)), Some(range));
670+
}
671+
672+
#[test]
673+
fn strong_etag_does_not_match_different() {
674+
let ir: IfRange = "\"abc123\"".parse().unwrap();
675+
let etag = HeaderValue::from_static("\"xyz789\"");
676+
let range = HttpRange::StartingPoint(0);
677+
678+
assert_eq!(ir.evaluate(range, None, Some(&etag)), None);
679+
}
680+
681+
#[test]
682+
fn strong_etag_does_not_match_missing() {
683+
let ir: IfRange = "\"abc123\"".parse().unwrap();
684+
let range = HttpRange::StartingPoint(0);
685+
686+
assert_eq!(ir.evaluate(range, None, None), None);
687+
}
688+
689+
#[test]
690+
fn weak_etag_never_matches_in_strong_comparison() {
691+
let ir: IfRange = "W/\"abc123\"".parse().unwrap();
692+
let etag = HeaderValue::from_static("W/\"abc123\"");
693+
let range = HttpRange::StartingPoint(0);
694+
695+
assert_eq!(ir.evaluate(range, None, Some(&etag)), None);
696+
}
697+
698+
#[test]
699+
fn weak_if_range_does_not_match_strong_etag() {
700+
let ir: IfRange = "W/\"abc123\"".parse().unwrap();
701+
let etag = HeaderValue::from_static("\"abc123\"");
702+
let range = HttpRange::StartingPoint(0);
703+
704+
assert_eq!(ir.evaluate(range, None, Some(&etag)), None);
705+
}
706+
707+
#[test]
708+
fn strong_if_range_does_not_match_weak_etag() {
709+
let ir: IfRange = "\"abc123\"".parse().unwrap();
710+
let etag = HeaderValue::from_static("W/\"abc123\"");
711+
let range = HttpRange::StartingPoint(0);
712+
713+
assert_eq!(ir.evaluate(range, None, Some(&etag)), None);
714+
}
715+
716+
#[test]
717+
fn date_ignores_etag() {
718+
let ir: IfRange = "Sun, 05 Apr 2026 04:49:21 GMT".parse().unwrap();
719+
let etag = HeaderValue::from_static("\"abc123\"");
720+
let range = HttpRange::StartingPoint(0);
721+
722+
// Date-based If-Range only checks Last-Modified, not ETag
723+
assert_eq!(ir.evaluate(range, None, Some(&etag)), None);
724+
}
725+
726+
#[test]
727+
fn etag_ignores_last_modified() {
728+
let ir: IfRange = "\"abc123\"".parse().unwrap();
729+
let lm = HeaderValue::from_static("Sun, 05 Apr 2026 04:49:21 GMT");
730+
let range = HttpRange::StartingPoint(0);
731+
732+
// ETag-based If-Range only checks ETag, not Last-Modified
733+
assert_eq!(ir.evaluate(range, Some(&lm), None), None);
734+
}
735+
}

0 commit comments

Comments
 (0)