aster_forge_utils/
http_validators.rs

1//! Transport-neutral HTTP conditional request helpers.
2
3use 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/// Errors produced while parsing HTTP validators.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15pub enum HttpValidatorError {
16    /// An entity-tag list contained a malformed tag or mixed `*` with tags.
17    #[error("invalid ETag list")]
18    InvalidEtagList,
19    /// A value was not a valid HTTP date.
20    #[error("invalid HTTP date")]
21    InvalidHttpDate,
22}
23
24/// Formats a system time as an IMF-fixdate HTTP date.
25#[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
31/// Validates that a system time is representable as an HTTP date without formatting it.
32///
33/// # Errors
34///
35/// Returns [`HttpValidatorError::InvalidHttpDate`] for times before the Unix epoch or after the
36/// maximum date supported by the HTTP-date representation.
37pub 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
47/// Formats a system time when it is representable by the HTTP-date implementation.
48///
49/// # Errors
50///
51/// Returns [`HttpValidatorError::InvalidHttpDate`] when `time` is outside the supported HTTP-date
52/// range.
53pub fn try_format_http_date(time: SystemTime) -> Result<String, HttpValidatorError> {
54    validate_http_date(time)?;
55    Ok(httpdate::fmt_http_date(time))
56}
57
58/// Trims and validates one entity-tag, quoting an opaque backend value when needed.
59///
60/// Already rendered strong and weak entity-tags are borrowed. The wildcard is a conditional
61/// request token rather than an entity-tag and is therefore rejected.
62///
63/// # Errors
64///
65/// Returns [`HttpValidatorError::InvalidEtagList`] when `value` is a wildcard or cannot be rendered
66/// as one valid entity-tag.
67pub 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
83/// Parses an HTTP date into system time.
84///
85/// # Errors
86///
87/// Returns [`HttpValidatorError::InvalidHttpDate`] when `value` is not a valid HTTP date.
88pub fn parse_http_date(value: &str) -> Result<SystemTime, HttpValidatorError> {
89    httpdate::parse_http_date(value).map_err(|_| HttpValidatorError::InvalidHttpDate)
90}
91
92/// Returns whole seconds relative to the Unix epoch, preserving pre-epoch ordering.
93#[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
101/// Applies the strong comparison required by `If-Match`.
102///
103/// # Errors
104///
105/// Returns [`HttpValidatorError::InvalidEtagList`] when the condition or current entity-tag is
106/// malformed or the condition exceeds the parser's element limit.
107pub 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
121/// Applies `If-Match` to all field lines in an HTTP header map.
122///
123/// # Errors
124///
125/// Returns [`HttpValidatorError::InvalidEtagList`] when the combined condition or current
126/// entity-tag is malformed or the condition exceeds the parser's element limit.
127pub 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
145/// Reports whether `If-Match` is present and contains at least one strong entity-tag.
146///
147/// The wildcard is a valid `If-Match` condition but is not itself an entity-tag, so it returns
148/// `Some(false)`. Malformed lists use the same parser and limits as conditional evaluation.
149///
150/// # Errors
151///
152/// Returns [`HttpValidatorError::InvalidEtagList`] when the combined `If-Match` value is malformed
153/// or exceeds the parser's element limit.
154pub 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(&current)))
180}
181
182/// Applies the weak comparison required by `If-None-Match`.
183///
184/// # Errors
185///
186/// Returns [`HttpValidatorError::InvalidEtagList`] when the condition or current entity-tag is
187/// malformed or the condition exceeds the parser's element limit.
188pub 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
202/// Applies `If-None-Match` to all field lines in an HTTP header map.
203///
204/// # Errors
205///
206/// Returns [`HttpValidatorError::InvalidEtagList`] when the combined condition or current
207/// entity-tag is malformed or the condition exceeds the parser's element limit.
208pub 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(&current)))
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}