From 7f0fd833af8de4335287f030452e2ec3fdc40151 Mon Sep 17 00:00:00 2001 From: fewensa Date: Sun, 13 Sep 2026 10:38:37 +0800 Subject: [PATCH] Add typed DPR request Client Hint metadata codeon: version: 1 authority: FWN-342 description: |- Implement bounded singleton DPR request Client Hint metadata across the protocol, client, server, and facade APIs. Add strict finite-positive decimal parsing with raw-header preservation, validated client emission, focused protocol/facade/HTTP/1.1 tests, and metadata-only documentation that excludes negotiation and automatic Client Hint emission. --- README.md | 15 +++ crates/rttp-client/README.md | 11 ++ crates/rttp-client/src/client.rs | 14 +++ crates/rttp-client/src/lib.rs | 1 + crates/rttp-client/tests/metadata_facade.rs | 18 ++- .../tests/test_raw_request_capture.rs | 67 +++++++++++ crates/rttp-protocol/README.md | 9 ++ crates/rttp-protocol/src/client_hints.rs | 104 ++++++++++++++++++ crates/rttp-protocol/tests/client_hints.rs | 51 ++++++++- crates/rttp-server/README.md | 7 ++ crates/rttp-server/src/server/request.rs | 26 +++++ crates/rttp-server/tests/metadata_facade.rs | 47 ++++++-- crates/rttp/README.md | 10 ++ crates/rttp/src/lib.rs | 8 +- ...metadata_http11_interoperability_matrix.rs | 28 +++++ 15 files changed, 399 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0fc73f6f..85740acb 100644 --- a/README.md +++ b/README.md @@ -1902,6 +1902,7 @@ gain additional HTTP/2 header-block handling. | Via | Client `via` emits bounded HTTP `Via` hop metadata through the shared protocol type; `Response::via` parses received hops; server `Request`/`HttpRequest` helpers and `HttpResponse::with_via`/`via` parse or declare caller-supplied chains while preserving raw headers on errors | No automatic hop insertion or removal, trusted-proxy inference, identity rewrite, or HTTP/1.1 or HTTP/2 proxy-policy changes | | Accept-Language | Client `accept_language` emits bounded `Accept-Language` request metadata through the protocol `AcceptLanguage` type; server `Request::accept_language()` and `HttpRequest::accept_language()` parse typed received values as `HttpAcceptLanguages` while preserving raw headers on errors | No locale matching, fallback selection, translation lookup, routing, or automatic response choice | | Preflight request metadata | Client `origin`, `access_control_request_method`, `access_control_request_headers`, and `access_control_request_private_network` emit bounded `Origin`, `Access-Control-Request-Method`, `Access-Control-Request-Headers`, and `Access-Control-Request-Private-Network` request metadata and reject invalid input before connecting | No automatic preflight decision, CORS policy, or Private Network Access policy | +| DPR request Client Hint | `HttpClient::dpr` emits bounded singleton `DPR` request metadata through `Dpr`; server `Request::dpr()` and `HttpRequest::dpr()` parse received fields as `HttpDpr` while preserving raw values on errors | No content negotiation, `Accept-CH` emission, automatic Client Hints generation, retry, or transport changes | | Access-Control-Allow-Credentials | Client `Response::access_control_allow_credentials` and server `HttpAccessControlAllowCredentials`, `HttpResponse::with_access_control_allow_credentials`, and `HttpResponse::access_control_allow_credentials` parse or declare bounded singleton `Access-Control-Allow-Credentials` `true`-token metadata while preserving raw headers on parse failures | No CORS request evaluation, automatic credential attachment, or automatic credentials granting | | Access-Control-Allow-Private-Network | Client `Response::access_control_allow_private_network` and server `HttpAccessControlAllowPrivateNetwork`, `HttpResponse::with_access_control_allow_private_network`, and `HttpResponse::access_control_allow_private_network` parse or declare bounded singleton `Access-Control-Allow-Private-Network` `true`-token metadata while preserving raw headers on parse failures | No private-network access grant, preflight decision, CORS policy, or Private Network Access policy | | Digest preferences | `want_content_digest`, `want_content_digest_with_q`, `want_repr_digest`, and `want_repr_digest_with_q` emit bounded `Want-Content-Digest` and `Want-Repr-Digest` request metadata; server `Request::want_content_digest()`, `HttpRequest::want_content_digest()`, `Request::want_repr_digest()`, and `HttpRequest::want_repr_digest()` parse received preference fields | No algorithm selection, digest computation, response body hash validation, retries, or signing | @@ -1922,6 +1923,7 @@ gain additional HTTP/2 header-block handling. | Content-Location | `Response::content_location` and `ContentLocation::parse` parse bounded singleton response `Content-Location` metadata while preserving raw headers | No redirect behavior, cache variant selection, representation replacement, retry/replay, route generation, or status-policy behavior | | Service-Worker-Allowed | `Response::service_worker_allowed` and `ServiceWorkerAllowed::parse` parse bounded singleton response `Service-Worker-Allowed` path metadata while preserving raw headers | No service-worker registration, scope evaluation, script-URL resolution, or application routing policy | | Content-DPR | `Response::content_dpr` and `ContentDpr::parse` parse bounded singleton response `Content-DPR` decimal-ratio metadata while preserving raw headers | No image rescaling, request DPR emission, Client Hints policy, retry, or transport changes | +| DPR | `Dpr::parse`, `HttpClient::dpr`, `Request::dpr`, and `HttpRequest::dpr` validate, emit, or parse bounded singleton request Client Hint metadata while preserving raw headers on errors | No content negotiation, `Accept-CH` emission, automatic Client Hints generation, retry, or transport changes | | Content-Type and Content-Encoding | `Response::content_type`/`ContentType::parse` parse bounded singleton `Content-Type` metadata, and `Response::content_encoding`/`ContentEncoding::parse` parse bounded ordered `Content-Encoding` codings while preserving raw headers on parse failures | No MIME sniffing, body decoding, charset transcoding, compression/decompression policy, negotiation, cache policy, redirects, retry/replay, or filesystem serving | | Connection | `Response::connection`/`Connection::parse` parse bounded HTTP/1 `Connection` tokens, combining duplicate fields in wire order while preserving raw headers on parse failures | No change to keep-alive, `auto_add_connection`, hop-by-hop stripping, or HTTP/2 rejection | | Transfer-Encoding | `Response::transfer_encoding`/`TransferEncoding::parse` parse bounded HTTP/1 `Transfer-Encoding` fields that must be sole `chunked`, combining duplicate fields in wire order while preserving raw headers on parse failures | No change to HTTP/1 framing decoders, `TE`, Content-Length, chunked body decoding policy, or HTTP/2 decode rejection | @@ -2715,6 +2717,19 @@ These helpers are observation-only. RTTP does not rescale images, send request DPR, apply Client Hints policy, retry, replay, redirect, or change transport from `Content-DPR`. +### Bounded DPR request Client Hint metadata + +`HttpClient::dpr(value)` validates and emits one singleton `DPR` request +Client Hint through the shared `Dpr` type. `Request::dpr()` and +`HttpRequest::dpr()` parse received fields into `HttpDpr`, exposing the finite +positive ratio with `ratio()` and the trimmed decimal text with +`header_value()`. Invalid, duplicate, non-finite, non-positive, control-byte, +and oversized values return parser errors while raw headers remain available. + +These helpers are metadata-only. RTTP does not negotiate content, emit +`Accept-CH`, generate Client Hints automatically, retry, replay, redirect, or +change transport from `DPR`. + ### Bounded HTTP/1.1 Content-Disposition behavior Server-side `Content-Disposition` helpers expose response metadata declaration diff --git a/crates/rttp-client/README.md b/crates/rttp-client/README.md index fac08150..2a243739 100644 --- a/crates/rttp-client/README.md +++ b/crates/rttp-client/README.md @@ -1355,6 +1355,17 @@ representation, compress a body, advertise Client Hints, or apply browser data-saver policy. Callers that need values outside the helper can retain raw-header control with `header(("Save-Data", "..."))`. +## Bounded DPR request Client Hint metadata + +`HttpClient::dpr(value)` validates and emits one bounded `DPR` request field +through the shared `Dpr` type, replacing any existing same-name field. +`Dpr::ratio()` exposes the finite positive ratio and `Dpr::header_value()` +returns the trimmed decimal text. Invalid, non-positive, non-finite, +control-byte, duplicate, and oversized values are rejected before connecting. +This helper declares metadata only; it does not negotiate content, emit +`Accept-CH`, or generate Client Hints automatically. Raw values remain +available through `header(("DPR", "..."))`. + ## Bounded DNT request metadata `HttpClient::dnt(value)` emits one `DNT` field from the user's declared diff --git a/crates/rttp-client/src/client.rs b/crates/rttp-client/src/client.rs index 6b180613..cabeab89 100644 --- a/crates/rttp-client/src/client.rs +++ b/crates/rttp-client/src/client.rs @@ -21,6 +21,7 @@ use rttp_protocol::access_control_request_private_network::AccessControlRequestP use rttp_protocol::authorization::Authorization; use rttp_protocol::baggage::Baggage; use rttp_protocol::cdn_loop::{CdnLoop, MAX_CDN_LOOP_VALUE_BYTES}; +use rttp_protocol::client_hints::Dpr; use rttp_protocol::depth::Depth; use rttp_protocol::destination::Destination; use rttp_protocol::dnt::Dnt; @@ -509,6 +510,19 @@ impl HttpClient { Ok(self.header(Header::new("Save-Data", save_data.header_value()))) } + /// Set bounded `DPR` request Client Hint metadata. + /// + /// The value must be one finite positive decimal ratio with optional + /// surrounding HTTP optional whitespace. This replaces any existing + /// case-insensitive `DPR` field and only declares request metadata; RTTP + /// does not negotiate content, emit `Accept-CH`, or generate this header + /// automatically. + pub fn dpr>(&mut self, value: S) -> error::Result<&mut Self> { + let dpr = Dpr::parse(value.as_ref()) + .map_err(|parse_error| error::builder_with_message(parse_error.to_string()))?; + Ok(self.header(Header::new("DPR", dpr.header_value()))) + } + /// Set `DNT` request metadata from the declared tracking preference. /// /// The value must be the W3C Tracking Preference Expression token `0` diff --git a/crates/rttp-client/src/lib.rs b/crates/rttp-client/src/lib.rs index 1d3c325b..188a5b90 100644 --- a/crates/rttp-client/src/lib.rs +++ b/crates/rttp-client/src/lib.rs @@ -132,6 +132,7 @@ pub use self::connection::{ConnectionReader, ResponseBodyReader, StreamingRespon pub use rttp_protocol::a_im::{AIm, AImMember, AImParameter, AImParseError}; pub use rttp_protocol::accept_datetime::{AcceptDatetime, AcceptDatetimeParseError}; pub use rttp_protocol::baggage::{Baggage, BaggageMember, BaggageParseError, BaggageProperty}; +pub use rttp_protocol::client_hints::{Dpr, DprParseError}; pub use rttp_protocol::dav::{Dav, DavClass, DavParseError}; pub use rttp_protocol::delta_base::{DeltaBase, DeltaBaseParseError}; pub use rttp_protocol::depth::{Depth, DepthParseError}; diff --git a/crates/rttp-client/tests/metadata_facade.rs b/crates/rttp-client/tests/metadata_facade.rs index 32f1b93d..cf2c69e5 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -46,11 +46,11 @@ use rttp_client::response::{ use rttp_client::{ AIm, AImMember, AImParameter, AImParseError, AcceptDatetime, AcceptDatetimeParseError, Baggage, BaggageMember, BaggageParseError, BaggageProperty, Depth, DepthParseError, Destination, - DestinationParseError, Dnt, DntParseError, EarlyData, EarlyDataParseError, Expect, - ExpectParseError, From, FromParseError, HttpClient, If, IfCondition, IfList, IfParseError, - IfPredicate, IfResourceTag, IfScheduleTagMatch, IfScheduleTagMatchParseError, IfStateToken, - Negotiate, NegotiateDirective, NegotiateParseError, Overwrite, OverwriteParseError, SecFetchDest, - SecFetchMode, SecFetchSite, SecFetchUser, SecGpc, SecGpcParseError, SecPurpose, + DestinationParseError, Dnt, DntParseError, Dpr, DprParseError, EarlyData, EarlyDataParseError, + Expect, ExpectParseError, From, FromParseError, HttpClient, If, IfCondition, IfList, + IfParseError, IfPredicate, IfResourceTag, IfScheduleTagMatch, IfScheduleTagMatchParseError, + IfStateToken, Negotiate, NegotiateDirective, NegotiateParseError, Overwrite, OverwriteParseError, + SecFetchDest, SecFetchMode, SecFetchSite, SecFetchUser, SecGpc, SecGpcParseError, SecPurpose, SecRequiredDocumentPolicy, SecRequiredDocumentPolicyDirective, SecRequiredDocumentPolicyParseError, SecRequiredDocumentPolicyValue, SecWebSocketKey, SecWebSocketKeyParseError, Tcn, TcnDirective, TcnParseError, Timeout, TimeoutParseError, @@ -69,6 +69,14 @@ fn client_facade_exports_from_metadata_types() { let _: FromParseError = From::parse("invalid").expect_err("invalid From should fail"); } +#[test] +fn client_facade_exports_dpr_metadata_types() { + let dpr = Dpr::parse("1.5").expect("DPR metadata should parse"); + assert_eq!(1.5, dpr.ratio()); + assert_eq!("1.5", dpr.header_value()); + let _: DprParseError = Dpr::parse("0").expect_err("zero DPR should fail"); +} + #[test] fn client_facade_exports_user_agent_metadata_types() { let user_agent: UserAgent = diff --git a/crates/rttp-client/tests/test_raw_request_capture.rs b/crates/rttp-client/tests/test_raw_request_capture.rs index aa3472df..41a1957d 100644 --- a/crates/rttp-client/tests/test_raw_request_capture.rs +++ b/crates/rttp-client/tests/test_raw_request_capture.rs @@ -5844,6 +5844,73 @@ fn save_data_helper_emits_on_request_token() { assert_eq!(Some("on"), header_value(&request, "Save-Data")); } +#[test] +fn dpr_helper_emits_one_canonical_request_client_hint() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", base_url)) + .header(("dpr", "2")) + .dpr("\t1.5\t") + .expect("DPR should be accepted") + .emit() + .expect("request should succeed"); + }); + let request = request_text(&request); + + assert_eq!(Some("1.5"), header_value(&request, "DPR")); + assert_eq!( + 1, + request + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("dpr:")) + .count(), + "typed DPR should replace an existing same-name field" + ); +} + +#[test] +fn dpr_helper_rejects_malformed_values_before_connecting() { + let oversized = "1".repeat(64 * 1024 + 1); + for value in [ + "", + "0", + "1e1", + "1, 2", + "inf", + "1\r\nInjected: yes", + oversized.as_str(), + ] { + let request = capture_optional_request(|base_url| { + let error = client() + .get() + .url(format!("{}/asset", base_url)) + .dpr(value) + .expect_err("invalid DPR input must be rejected"); + assert!(error.is_builder()); + }); + assert!( + request.is_empty(), + "invalid DPR input must not open a socket" + ); + } +} + +#[test] +fn raw_dpr_header_remains_available_as_escape_hatch() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", base_url)) + .header(("DPR", "legacy-token")) + .emit() + .expect("request should succeed"); + }); + let request = request_text(&request); + + assert_eq!(Some("legacy-token"), header_value(&request, "DPR")); +} + #[test] fn dnt_helper_emits_defined_preference_tokens() { let request = capture_request(|base_url| { diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index 1093740f..ab3c3a28 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -606,6 +606,15 @@ notation, leftover characters, and forbidden ASCII control bytes are errors. This parser reports declared metadata only; it does not rescale images, send request DPR, apply Client Hints policy, retry, or change transport. +## DPR + +`client_hints::Dpr` parses a singleton HTTP `DPR` request Client Hint as a +finite positive decimal ratio. The field value is bounded to 64 KiB, optional +outer SP and HTAB are trimmed, and the accepted decimal text is preserved by +`header_value()`. Empty, malformed, duplicate, non-finite, non-positive, +control-byte, and oversized values are errors. This parser reports request +metadata only; it does not negotiate content or emit Client Hints. + ## Memento-Datetime `memento_datetime` parses a singleton `Memento-Datetime` field as one diff --git a/crates/rttp-protocol/src/client_hints.rs b/crates/rttp-protocol/src/client_hints.rs index d537d0cc..5ae30474 100644 --- a/crates/rttp-protocol/src/client_hints.rs +++ b/crates/rttp-protocol/src/client_hints.rs @@ -3,6 +3,13 @@ use std::fmt; pub const MAX_CLIENT_HINT_VALUE_BYTES: usize = 64 * 1024; pub const MAX_CLIENT_HINT_NAMES: usize = 256; +pub const MAX_DPR_VALUE_BYTES: usize = 64 * 1024; + +/// Parsed, bounded `DPR` request Client Hint metadata. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Dpr { + value: String, +} /// Parsed, bounded `Accept-CH` response metadata. #[derive(Clone, Debug, Eq, PartialEq)] @@ -23,6 +30,7 @@ pub struct ClientHintsParseError { pub type AcceptChParseError = ClientHintsParseError; pub type CriticalChParseError = ClientHintsParseError; +pub type DprParseError = ClientHintsParseError; impl ClientHintsParseError { fn new(message: impl Into) -> Self { @@ -40,6 +48,32 @@ impl fmt::Display for ClientHintsParseError { impl Error for ClientHintsParseError {} +impl Dpr { + pub fn parse(value: impl AsRef) -> Result { + Self::parse_values([value.as_ref()]) + } + + pub fn parse_values<'a, I>(values: I) -> Result + where + I: IntoIterator, + { + let value = parse_dpr_singleton(values)?; + let value = value.trim_matches([' ', '\t']); + parse_dpr_ratio(value)?; + Ok(Self { + value: value.to_string(), + }) + } + + pub fn ratio(&self) -> f64 { + parse_dpr_ratio(&self.value).expect("DPR values are validated at construction") + } + + pub fn header_value(&self) -> String { + self.value.clone() + } +} + impl AcceptCh { pub fn parse(value: impl AsRef) -> Result { Self::parse_values([value.as_ref()]) @@ -139,6 +173,76 @@ where Ok(client_hints) } +fn parse_dpr_singleton<'a, I>(values: I) -> Result<&'a str, DprParseError> +where + I: IntoIterator, +{ + let mut values = values.into_iter(); + let value = values.next().ok_or_else(invalid_dpr_value)?; + validate_bounded_dpr_value(value)?; + let mut has_duplicate = false; + for value in values { + has_duplicate = true; + validate_bounded_dpr_value(value)?; + } + if has_duplicate { + return Err(ClientHintsParseError::new("duplicate DPR header fields")); + } + Ok(value) +} + +fn validate_bounded_dpr_value(value: &str) -> Result<(), DprParseError> { + if value.len() > MAX_DPR_VALUE_BYTES { + return Err(ClientHintsParseError::new("DPR header value is too large")); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(ClientHintsParseError::new("invalid DPR control byte")); + } + Ok(()) +} + +fn parse_dpr_ratio(value: &str) -> Result { + if !matches_dpr_grammar(value) { + return Err(invalid_dpr_value()); + } + let ratio: f64 = value.parse().map_err(|_| invalid_dpr_value())?; + if !ratio.is_finite() || ratio <= 0.0 { + return Err(invalid_dpr_value()); + } + Ok(ratio) +} + +fn matches_dpr_grammar(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_digit() { + return false; + } + + let mut index = 0; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if index == bytes.len() { + return true; + } + if bytes[index] != b'.' { + return false; + } + index += 1; + let fraction_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + index > fraction_start && index == bytes.len() +} + +fn invalid_dpr_value() -> DprParseError { + ClientHintsParseError::new("invalid DPR header value") +} + fn is_structured_token(value: &str) -> bool { let mut bytes = value.bytes(); matches!(bytes.next(), Some(b'*' | b'a'..=b'z' | b'A'..=b'Z')) diff --git a/crates/rttp-protocol/tests/client_hints.rs b/crates/rttp-protocol/tests/client_hints.rs index f57da27b..bc3c801d 100644 --- a/crates/rttp-protocol/tests/client_hints.rs +++ b/crates/rttp-protocol/tests/client_hints.rs @@ -1,5 +1,6 @@ use rttp_protocol::client_hints::{ - AcceptCh, CriticalCh, MAX_CLIENT_HINT_NAMES, MAX_CLIENT_HINT_VALUE_BYTES, + AcceptCh, CriticalCh, Dpr, MAX_CLIENT_HINT_NAMES, MAX_CLIENT_HINT_VALUE_BYTES, + MAX_DPR_VALUE_BYTES, }; #[test] @@ -69,3 +70,51 @@ fn client_hint_headers_enforce_value_and_member_bounds() { assert!(AcceptCh::parse(&too_many).is_err()); assert!(CriticalCh::parse(&too_many).is_err()); } + +#[test] +fn dpr_parses_positive_finite_decimal_and_round_trips() { + for (value, ratio) in [("1", 1.0), ("2.0", 2.0), ("1.5", 1.5)] { + let dpr = Dpr::parse(value).expect("valid DPR"); + assert_eq!(ratio, dpr.ratio()); + assert_eq!(value, dpr.header_value()); + assert_eq!(dpr, Dpr::parse(dpr.header_value()).expect("DPR roundtrip")); + } +} + +#[test] +fn dpr_trims_outer_optional_whitespace() { + let dpr = Dpr::parse("\t 1.5 \t").expect("OWS-padded DPR"); + assert_eq!(1.5, dpr.ratio()); + assert_eq!("1.5", dpr.header_value()); +} + +#[test] +fn dpr_rejects_malformed_duplicate_empty_non_finite_and_non_positive_values() { + assert!(Dpr::parse_values(["1", "2"]).is_err()); + assert!(Dpr::parse_values([]).is_err()); + + for value in [ + "", " ", "0", "0.0", "00", "2.", ".5", "+1", "-1", "1e1", "1E1", "1.5.0", "1, 2", "1 5", "inf", + "nan", + ] { + assert!(Dpr::parse(value).is_err(), "{value:?} must be rejected"); + } +} + +#[test] +fn dpr_rejects_oversized_and_control_byte_values() { + assert!(Dpr::parse("1".repeat(MAX_DPR_VALUE_BYTES + 1)).is_err()); + assert!(Dpr::parse("1\r\nInjected: yes").is_err()); + assert!(Dpr::parse("1\u{7f}").is_err()); +} + +#[test] +fn dpr_checks_duplicate_values_against_the_bound() { + let oversized = "1".repeat(MAX_DPR_VALUE_BYTES + 1); + assert!(Dpr::parse_values(["1.5", oversized.as_str()]).is_err()); +} + +#[test] +fn dpr_rejects_non_finite_oversized_digits() { + assert!(Dpr::parse("9".repeat(400)).is_err()); +} diff --git a/crates/rttp-server/README.md b/crates/rttp-server/README.md index 5d21e307..cedcbc42 100644 --- a/crates/rttp-server/README.md +++ b/crates/rttp-server/README.md @@ -484,6 +484,13 @@ when the field is absent. The helpers only declare and inspect metadata. The server does not retain per-client opt-ins, select hints, alter response policy, or trigger retries. +`Request::dpr()` parses a singleton `DPR` request Client Hint into +`HttpDpr`, exposing its finite positive ratio through `ratio()` and the +trimmed wire value through `header_value()`. Malformed or duplicate fields +return `HttpDprParseError` while `Request::header("DPR")` preserves the raw +value. This is metadata-only: the server does not negotiate content or emit +Client Hints automatically. + ## Digest response metadata `HttpResponse::with_digest(value)` and `HttpResponse::with_repr_digest(value)` diff --git a/crates/rttp-server/src/server/request.rs b/crates/rttp-server/src/server/request.rs index 88ca4c74..e8844a31 100644 --- a/crates/rttp-server/src/server/request.rs +++ b/crates/rttp-server/src/server/request.rs @@ -46,6 +46,7 @@ pub use rttp_protocol::cdn_loop::{ CdnLoop as HttpCdnLoop, CdnLoopMember as HttpCdnLoopMember, CdnLoopParameter as HttpCdnLoopParameter, CdnLoopParseError as HttpCdnLoopParseError, }; +pub use rttp_protocol::client_hints::{Dpr as HttpDpr, DprParseError as HttpDprParseError}; pub use rttp_protocol::connection::{ Connection as HttpConnection, ConnectionParseError as HttpConnectionParseError, }; @@ -489,6 +490,16 @@ impl Request { HttpRequestCacheControl::parse_values(values).map(Some) } + /// Parses received bounded `DPR` request Client Hint metadata without + /// negotiating content or emitting Client Hints. + pub fn dpr(&self) -> Result, HttpDprParseError> { + let values: Vec<&str> = self.headers_named("DPR").collect(); + if values.is_empty() { + return Ok(None); + } + HttpDpr::parse_values(values).map(Some) + } + /// Parses received `Sec-Fetch-Site` metadata without enforcing browser policy. pub fn sec_fetch_site(&self) -> Result, HttpFetchMetadataParseError> { rttp_protocol::fetch_metadata::parse_optional_value( @@ -2797,6 +2808,21 @@ impl HttpRequest { HttpSaveData::parse_values(values).map(Some) } + /// Parses received bounded `DPR` request Client Hint metadata without + /// negotiating content or emitting Client Hints. + pub fn dpr(&self) -> Result, HttpDprParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("DPR")) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpDpr::parse_values(values).map(Some) + } + /// Parses received `DNT` tracking-preference metadata without applying /// tracking, cookie, analytics, or advertising policy. pub fn dnt(&self) -> Result, HttpDntParseError> { diff --git a/crates/rttp-server/tests/metadata_facade.rs b/crates/rttp-server/tests/metadata_facade.rs index 33120602..9cc27d9b 100644 --- a/crates/rttp-server/tests/metadata_facade.rs +++ b/crates/rttp-server/tests/metadata_facade.rs @@ -23,13 +23,14 @@ use rttp_server::server::{ HttpDepthParseError, HttpDnt, HttpDntParseError, HttpDocumentPolicy, HttpDocumentPolicyDirective, HttpDocumentPolicyParseError, HttpDocumentPolicyReportOnly, HttpDocumentPolicyReportOnlyParseError, HttpDocumentPolicyReportOnlyValue, - HttpDocumentPolicyValue, HttpEarlyData, HttpEarlyDataParseError, HttpEntityTag, - HttpExpectParseError, HttpExpectations, HttpExpiresParseError, HttpFrom, HttpFromParseError, - HttpHost, HttpIdempotencyKey, HttpIdempotencyKeyParseError, HttpIf, HttpIfCondition, HttpIfList, - HttpIfModifiedSince, HttpIfModifiedSinceParseError, HttpIfParseError, HttpIfPredicate, - HttpIfResourceTag, HttpIfScheduleTagMatch, HttpIfScheduleTagMatchParseError, HttpIfStateToken, - HttpIfUnmodifiedSince, HttpIfUnmodifiedSinceParseError, HttpIm, HttpImMember, HttpImParameter, - HttpImParseError, HttpKeepAlive, HttpLockToken, HttpLockTokenParseError, HttpMaxForwards, + HttpDocumentPolicyValue, HttpDpr, HttpDprParseError, HttpEarlyData, HttpEarlyDataParseError, + HttpEntityTag, HttpExpectParseError, HttpExpectations, HttpExpiresParseError, HttpFrom, + HttpFromParseError, HttpHost, HttpIdempotencyKey, HttpIdempotencyKeyParseError, HttpIf, + HttpIfCondition, HttpIfList, HttpIfModifiedSince, HttpIfModifiedSinceParseError, + HttpIfParseError, HttpIfPredicate, HttpIfResourceTag, HttpIfScheduleTagMatch, + HttpIfScheduleTagMatchParseError, HttpIfStateToken, HttpIfUnmodifiedSince, + HttpIfUnmodifiedSinceParseError, HttpIm, HttpImMember, HttpImParameter, HttpImParseError, + HttpKeepAlive, HttpLockToken, HttpLockTokenParseError, HttpMaxForwards, HttpMaxForwardsParseError, HttpMementoDatetime, HttpMementoDatetimeParseError, HttpNegotiate, HttpNegotiateDirective, HttpNegotiateParseError, HttpNoVarySearch, HttpNoVarySearchParams, HttpOriginTrialParseError, HttpOriginTrials, HttpOverwrite, HttpOverwriteParseError, @@ -1800,6 +1801,38 @@ fn request_facade_parses_from_metadata_without_policy() { ); } +#[test] +fn request_facade_parses_dpr_metadata_without_negotiation() { + let request = + HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\nDPR: \t1.5 \t\r\n\r\n") + .expect("DPR request should parse"); + let dpr: HttpDpr = request + .dpr() + .expect("DPR should parse") + .expect("DPR should be present"); + assert_eq!(1.5, dpr.ratio()); + assert_eq!("1.5", dpr.header_value()); + assert_eq!(Some("1.5"), request.header("DPR")); + + let absent = HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\n\r\n") + .expect("request without DPR should parse"); + assert_eq!(None, absent.dpr().expect("missing DPR should be valid")); + + let malformed = + HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\nDPR: 1e1\r\n\r\n") + .expect("malformed DPR request should retain raw metadata"); + assert!(malformed.dpr().is_err()); + assert_eq!(Some("1e1"), malformed.header("DPR")); + + let duplicate = + HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\nDPR: 1\r\nDPR: 2\r\n\r\n") + .expect("duplicate DPR request should retain raw metadata"); + let _: HttpDprParseError = duplicate.dpr().expect_err("duplicate DPR should fail"); + assert_eq!(Some("1"), duplicate.header("DPR")); + + let _: HttpDprParseError = HttpDpr::parse("").expect_err("empty DPR should fail"); +} + #[test] fn request_facade_parses_referer_metadata_without_policy() { let absolute = HttpRequest::parse( diff --git a/crates/rttp/README.md b/crates/rttp/README.md index c977f2aa..c1340085 100644 --- a/crates/rttp/README.md +++ b/crates/rttp/README.md @@ -596,6 +596,16 @@ This is syntax-only metadata. RTTP does not fingerprint clients, discover platform details, apply product policy, synthesize a default through the typed accessor, or change behavior based on the declared user agent. +## Bounded DPR request Client Hint metadata + +With the client feature, `HttpClient::dpr(value)` validates and emits one +singleton `DPR` request field through `rttp::Dpr`, replacing any existing +case-insensitive field. On the server facade, `Request::dpr()` and +`HttpRequest::dpr()` parse received fields into `HttpDpr`; `ratio()` and +`header_value()` expose the typed and preserved values. Parse errors leave +the raw header available. These helpers only expose metadata: RTTP does not +negotiate content, emit `Accept-CH`, or generate Client Hints automatically. + ## Bounded Idempotency-Key request metadata `HttpClient::idempotency_key(value)` validates and emits one opaque diff --git a/crates/rttp/src/lib.rs b/crates/rttp/src/lib.rs index 0759407a..25cedfed 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -57,10 +57,10 @@ pub use rttp_client::response::{ pub use rttp_client::{ AIm, AImMember, AImParameter, AImParseError, AcceptDatetime, AcceptDatetimeParseError, Baggage, BaggageMember, BaggageParseError, BaggageProperty, Depth, DepthParseError, Destination, - DestinationParseError, Dnt, DntParseError, EarlyData, EarlyDataParseError, Expect, - ExpectParseError, From, FromParseError, If, IfCondition, IfList, IfParseError, IfPredicate, - IfResourceTag, IfScheduleTagMatch, IfScheduleTagMatchParseError, IfStateToken, LockToken, - LockTokenParseError, Negotiate, NegotiateDirective, NegotiateParseError, Overwrite, + DestinationParseError, Dnt, DntParseError, Dpr, DprParseError, EarlyData, EarlyDataParseError, + Expect, ExpectParseError, From, FromParseError, If, IfCondition, IfList, IfParseError, + IfPredicate, IfResourceTag, IfScheduleTagMatch, IfScheduleTagMatchParseError, IfStateToken, + LockToken, LockTokenParseError, Negotiate, NegotiateDirective, NegotiateParseError, Overwrite, OverwriteParseError, Referer, RefererParseError, SecFetchDest, SecFetchMode, SecFetchSite, SecFetchUser, SecPurpose, SecRequiredDocumentPolicy, SecRequiredDocumentPolicyDirective, SecRequiredDocumentPolicyParseError, SecRequiredDocumentPolicyValue, SecWebSocketKey, diff --git a/tests/metadata_http11_interoperability_matrix.rs b/tests/metadata_http11_interoperability_matrix.rs index 378b6066..3ef82381 100644 --- a/tests/metadata_http11_interoperability_matrix.rs +++ b/tests/metadata_http11_interoperability_matrix.rs @@ -15,6 +15,7 @@ use rttp_server::server::{ const FROM_CANONICAL: &str = "Ops Team "; const REFERER_CANONICAL: &str = "https://shop.example/checkout?step=pay"; +const DPR_CANONICAL: &str = "1.5"; const ACCEPT_PATCH_WIRE: &str = r#"Text/Plain; title="a,b\"c", application/json"#; const ACCEPT_POST_WIRE: &str = "application/json, text/plain; charset=utf-8"; const RATE_LIMIT_LIMIT_WIRE: &str = "100, 50;w=3600"; @@ -46,6 +47,9 @@ struct ObservedRequestMetadata { raw_from: Option, referer: Result, String>, raw_referer: Option, + dpr: Result, String>, + dpr_ratio: Option, + raw_dpr: Option, } fn client() -> HttpClient { @@ -78,6 +82,12 @@ fn observe_request(request: &Request) -> ObservedRequestMetadata { .map(|referer| referer.map(|referer| referer.header_value())) .map_err(|error| error.to_string()), raw_referer: request.header("Referer").map(str::to_string), + dpr: request + .dpr() + .map(|dpr| dpr.map(|dpr| dpr.header_value())) + .map_err(|error| error.to_string()), + dpr_ratio: request.dpr().ok().flatten().map(|dpr| dpr.ratio()), + raw_dpr: request.header("DPR").map(str::to_string), } } @@ -135,6 +145,8 @@ fn attach_valid_client_metadata(client: &mut HttpClient) -> &mut HttpClient { .expect("From should be accepted") .referer("\thttps://shop.example/checkout?step=pay\t") .expect("Referer should be accepted") + .dpr("\t1.5\t") + .expect("DPR should be accepted") } fn assert_valid_request_metadata(observed: &ObservedRequestMetadata) { @@ -145,6 +157,9 @@ fn assert_valid_request_metadata(observed: &ObservedRequestMetadata) { assert_eq!(Some(FROM_CANONICAL.to_string()), observed.raw_from); assert_eq!(Ok(Some(REFERER_CANONICAL.to_string())), observed.referer); assert_eq!(Some(REFERER_CANONICAL.to_string()), observed.raw_referer); + assert_eq!(Ok(Some(DPR_CANONICAL.to_string())), observed.dpr); + assert_eq!(Some(1.5), observed.dpr_ratio); + assert_eq!(Some(DPR_CANONICAL.to_string()), observed.raw_dpr); } fn assert_valid_response_metadata(response: &Response) { @@ -527,6 +542,12 @@ fn typed_request_helpers_reject_malformed_values_before_connect() { reject_before_connect("oversized Referer", |client| { client.referer("a".repeat(64 * 1024 + 1)) }); + reject_before_connect("malformed DPR", |client| client.dpr("1e1")); + reject_before_connect("non-positive DPR", |client| client.dpr("0")); + reject_before_connect("DPR with control byte", |client| client.dpr("1\0")); + reject_before_connect("oversized DPR", |client| { + client.dpr("1".repeat(64 * 1024 + 1)) + }); } #[test] @@ -585,6 +606,7 @@ fn facade_server_preserves_raw_headers_when_typed_request_helpers_reject_malform Host: example.test\r\n\ From: ops\r\n\ Referer: https://example.test/path#frag\r\n\ +DPR: 1e1\r\n\ Connection: close\r\n\ \r\n", ) @@ -605,6 +627,8 @@ Connection: close\r\n\ Some("https://example.test/path#frag".to_string()), observed.raw_referer ); + assert!(observed.dpr.is_err()); + assert_eq!(Some("1e1".to_string()), observed.raw_dpr); assert!( response.starts_with("HTTP/1.1 200 "), "malformed typed metadata must not fail the HTTP exchange: {response}" @@ -630,6 +654,8 @@ From: ops@example.test\r\n\ from: other@example.test\r\n\ Referer: https://shop.example/a\r\n\ referer: https://shop.example/b\r\n\ +DPR: 1\r\n\ +dpr: 2\r\n\ Connection: close\r\n\ \r\n", ) @@ -645,6 +671,8 @@ Connection: close\r\n\ Some("https://shop.example/a".to_string()), observed.raw_referer ); + assert!(observed.dpr.is_err()); + assert_eq!(Some("1".to_string()), observed.raw_dpr); handle.join().expect("duplicate request server thread"); }