diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ab79d..af251db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Fixed + +- Combine repeated `Accept`, `Accept-Encoding`, and `If-None-Match` field + lines before evaluating negotiation and validators. +- Enforce the RFC 9110 qvalue grammar instead of clamping arbitrary + floating-point values. + ## [0.1.1] - 2026-07-27 ### Fixed diff --git a/src/lib.rs b/src/lib.rs index 3473338..c5b946e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -336,11 +336,7 @@ where let br = A::get(&br_path); let gzip = A::get(&gzip_path); let has_alternates = br.is_some() || gzip.is_some(); - let accept_encoding = headers - .get(header::ACCEPT_ENCODING) - .and_then(|value| value.to_str().ok()); - - let identity_quality = encoding_quality(accept_encoding, "identity"); + let identity_quality = encoding_quality(headers, "identity"); let mut selected = if identity_quality > 0 { Some((identity_quality, logical_path.to_owned(), identity, None)) } else { @@ -348,14 +344,14 @@ where }; if let Some(file) = gzip { - let quality = encoding_quality(accept_encoding, "gzip"); + let quality = encoding_quality(headers, "gzip"); if quality > 0 && selected.as_ref().is_none_or(|current| quality >= current.0) { selected = Some((quality, gzip_path, file, Some("gzip"))); } } if let Some(file) = br { - let quality = encoding_quality(accept_encoding, "br"); + let quality = encoding_quality(headers, "br"); if quality > 0 && selected.as_ref().is_none_or(|current| quality >= current.0) { selected = Some((quality, br_path, file, Some("br"))); } @@ -371,40 +367,60 @@ where ) } -fn encoding_quality(header_value: Option<&str>, coding: &str) -> u16 { - let Some(header_value) = header_value else { - return u16::from(coding == "identity") * 1000; - }; - +fn encoding_quality(headers: &HeaderMap, coding: &str) -> u16 { let mut wildcard = None; let mut exact = None; + let mut saw_value = false; - for item in header_value.split(',') { - let mut parts = item.trim().split(';'); - let name = parts.next().unwrap_or_default().trim(); - let quality = parts - .find_map(|parameter| { - let (key, value) = parameter.trim().split_once('=')?; - key.eq_ignore_ascii_case("q") - .then(|| parse_quality(value.trim())) - }) - .unwrap_or(1000); - - if name.eq_ignore_ascii_case(coding) { - exact = Some(quality); - } else if name == "*" { - wildcard = Some(quality); + for header_value in headers.get_all(header::ACCEPT_ENCODING) { + let Ok(header_value) = header_value.to_str() else { + continue; + }; + saw_value = true; + + for item in header_value.split(',') { + let mut parts = item.trim().split(';'); + let name = parts.next().unwrap_or_default().trim(); + let quality = parts + .find_map(|parameter| { + let (key, value) = parameter.trim().split_once('=')?; + key.eq_ignore_ascii_case("q") + .then(|| parse_quality(value.trim()).unwrap_or(0)) + }) + .unwrap_or(1000); + + if name.eq_ignore_ascii_case(coding) { + exact = Some(quality); + } else if name == "*" { + wildcard = Some(quality); + } } } + if !saw_value { + return u16::from(coding == "identity") * 1000; + } + exact.unwrap_or_else(|| wildcard.unwrap_or_else(|| u16::from(coding == "identity") * 1000)) } -fn parse_quality(value: &str) -> u16 { - value - .parse::() - .map(|quality| (quality.clamp(0.0, 1.0) * 1000.0).round() as u16) - .unwrap_or(0) +fn parse_quality(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').map_or((value, ""), |parts| parts); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + match whole { + "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), + "0" => { + let mut padded = fraction.to_owned(); + while padded.len() < 3 { + padded.push('0'); + } + padded.parse().ok() + } + _ => None, + } } fn strong_etag(hash: [u8; 32]) -> HeaderValue { @@ -421,21 +437,20 @@ fn strong_etag(hash: [u8; 32]) -> HeaderValue { } fn etag_matches(headers: &HeaderMap, current: &HeaderValue) -> bool { - let Some(candidate) = headers - .get(header::IF_NONE_MATCH) - .and_then(|value| value.to_str().ok()) - else { - return false; - }; let Ok(current) = current.to_str() else { return false; }; let current = current.strip_prefix("W/").unwrap_or(current); - candidate.split(',').any(|tag| { - let tag = tag.trim(); - tag == "*" || tag.strip_prefix("W/").unwrap_or(tag) == current - }) + headers + .get_all(header::IF_NONE_MATCH) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|tag| { + let tag = tag.trim(); + tag == "*" || tag.strip_prefix("W/").unwrap_or(tag) == current + }) } fn safe_asset_path(uri: &Uri, index_path: &str) -> Option { @@ -462,22 +477,22 @@ fn is_safe_relative_path(path: &str) -> bool { fn accepts_html(headers: &HeaderMap) -> bool { headers - .get(header::ACCEPT) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value.split(',').any(|item| { - let mut parts = item.trim().split(';'); - let media_type = parts.next().unwrap_or_default().trim(); - let quality = parts - .find_map(|parameter| { - let (key, value) = parameter.trim().split_once('=')?; - key.eq_ignore_ascii_case("q") - .then(|| parse_quality(value.trim())) - }) - .unwrap_or(1000); - - media_type.eq_ignore_ascii_case("text/html") && quality > 0 - }) + .get_all(header::ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|item| { + let mut parts = item.trim().split(';'); + let media_type = parts.next().unwrap_or_default().trim(); + let quality = parts + .find_map(|parameter| { + let (key, value) = parameter.trim().split_once('=')?; + key.eq_ignore_ascii_case("q") + .then(|| parse_quality(value.trim()).unwrap_or(0)) + }) + .unwrap_or(1000); + + media_type.eq_ignore_ascii_case("text/html") && quality > 0 }) } @@ -496,24 +511,60 @@ mod tests { #[test] fn absent_accept_encoding_prefers_identity() { - assert_eq!(encoding_quality(None, "identity"), 1000); - assert_eq!(encoding_quality(None, "gzip"), 0); - assert_eq!(encoding_quality(None, "br"), 0); + let headers = HeaderMap::new(); + assert_eq!(encoding_quality(&headers, "identity"), 1000); + assert_eq!(encoding_quality(&headers, "gzip"), 0); + assert_eq!(encoding_quality(&headers, "br"), 0); } #[test] fn quality_values_and_wildcards_are_respected() { - let value = Some("gzip;q=0.6, br;q=1, *;q=0.2"); - assert_eq!(encoding_quality(value, "br"), 1000); - assert_eq!(encoding_quality(value, "gzip"), 600); - assert_eq!(encoding_quality(value, "zstd"), 200); - assert_eq!(encoding_quality(Some("*;q=0"), "identity"), 0); + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip;q=0.6, br;q=1, *;q=0.2"), + ); + assert_eq!(encoding_quality(&headers, "br"), 1000); + assert_eq!(encoding_quality(&headers, "gzip"), 600); + assert_eq!(encoding_quality(&headers, "zstd"), 200); + + headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("*;q=0")); + assert_eq!(encoding_quality(&headers, "identity"), 0); + } + + #[test] + fn repeated_accept_encoding_fields_are_combined() { + let mut headers = HeaderMap::new(); + headers.append( + header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip;q=0.4"), + ); + headers.append(header::ACCEPT_ENCODING, HeaderValue::from_static("br;q=1")); + + assert_eq!(encoding_quality(&headers, "gzip"), 400); + assert_eq!(encoding_quality(&headers, "br"), 1000); + } + + #[test] + fn quality_values_follow_the_http_grammar() { + assert_eq!(parse_quality("0"), Some(0)); + assert_eq!(parse_quality("0.5"), Some(500)); + assert_eq!(parse_quality("0.123"), Some(123)); + assert_eq!(parse_quality("1.000"), Some(1000)); + assert_eq!(parse_quality("1.001"), None); + assert_eq!(parse_quality("0.1234"), None); + assert_eq!(parse_quality("-0.1"), None); + assert_eq!(parse_quality("NaN"), None); } #[test] fn weak_if_none_match_can_validate_a_strong_etag_for_get() { let mut headers = HeaderMap::new(); - headers.insert( + headers.append( + header::IF_NONE_MATCH, + HeaderValue::from_static("\"sha256-other\""), + ); + headers.append( header::IF_NONE_MATCH, HeaderValue::from_static("W/\"sha256-example\""), ); @@ -548,6 +599,15 @@ mod tests { assert!(!accepts_html(&headers)); } + #[test] + fn repeated_accept_fields_are_combined() { + let mut headers = HeaderMap::new(); + headers.append(header::ACCEPT, HeaderValue::from_static("application/json")); + headers.append(header::ACCEPT, HeaderValue::from_static("text/html")); + + assert!(accepts_html(&headers)); + } + #[test] fn webmanifest_uses_registered_manifest_media_type() { assert_eq!( diff --git a/tests/http_contract.rs b/tests/http_contract.rs index f636c1c..6a1f71d 100644 --- a/tests/http_contract.rs +++ b/tests/http_contract.rs @@ -55,6 +55,38 @@ async fn identity_gzip_and_brotli_have_distinct_strong_etags() { assert!(!identity_etag.to_str().unwrap().starts_with("W/")); } +#[tokio::test] +async fn repeated_accept_encoding_fields_select_the_best_representation() { + let response = spa().serve( + Request::builder() + .uri("/") + .header(header::ACCEPT_ENCODING, "gzip;q=0.4") + .header(header::ACCEPT_ENCODING, "br;q=1") + .body(Body::empty()) + .unwrap(), + ); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_ENCODING], "br"); +} + +#[tokio::test] +async fn invalid_quality_values_are_not_clamped_into_acceptance() { + let response = spa().serve( + Request::builder() + .uri("/") + .header( + header::ACCEPT_ENCODING, + "identity;q=0, gzip;q=0.5, br;q=1.0000", + ) + .body(Body::empty()) + .unwrap(), + ); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_ENCODING], "gzip"); +} + #[tokio::test] async fn matching_validator_returns_bodyless_304() { let spa = spa(); @@ -113,6 +145,34 @@ async fn html_navigation_falls_back_but_missing_asset_does_not() { assert_eq!(missing_asset.headers()[header::CACHE_CONTROL], "no-store"); } +#[tokio::test] +async fn repeated_accept_fields_can_enable_html_fallback() { + let response = spa().serve( + Request::builder() + .uri("/rooms/ABCD") + .header(header::ACCEPT, "application/json") + .header(header::ACCEPT, "text/html") + .body(Body::empty()) + .unwrap(), + ); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html"); +} + +#[tokio::test] +async fn invalid_html_quality_does_not_enable_fallback() { + let response = spa().serve( + Request::builder() + .uri("/rooms/ABCD") + .header(header::ACCEPT, "text/html;q=0.9999") + .body(Body::empty()) + .unwrap(), + ); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn direct_compressed_paths_are_hidden() { let response = spa().serve(request("/index.html.br"));