From 3c30db8561d1a8cd455338b391cae38f8abe9738 Mon Sep 17 00:00:00 2001 From: fewensa Date: Sun, 13 Sep 2026 12:51:48 +0800 Subject: [PATCH] Add typed ECT request Client Hint metadata codeon: version: 1 authority: FWN-348 description: |- Parse and emit singleton ECT Client Hint values with Network Information tokens through the existing DPR-style typed metadata path, including client emit, server parse accessors, focused tests, and brief README coverage. --- crates/rttp-client/src/client.rs | 15 +++- crates/rttp-client/src/lib.rs | 2 +- .../tests/test_raw_request_capture.rs | 51 +++++++++++++ crates/rttp-protocol/src/client_hints.rs | 71 +++++++++++++++++++ crates/rttp-protocol/tests/client_hints.rs | 51 ++++++++++++- crates/rttp-server/src/server/request.rs | 30 +++++++- crates/rttp-server/tests/metadata_facade.rs | 32 +++++++++ crates/rttp/README.md | 12 ++++ crates/rttp/src/lib.rs | 22 +++--- 9 files changed, 270 insertions(+), 16 deletions(-) diff --git a/crates/rttp-client/src/client.rs b/crates/rttp-client/src/client.rs index cabeab89..542d2a9c 100644 --- a/crates/rttp-client/src/client.rs +++ b/crates/rttp-client/src/client.rs @@ -21,7 +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::client_hints::{Dpr, Ect}; use rttp_protocol::depth::Depth; use rttp_protocol::destination::Destination; use rttp_protocol::dnt::Dnt; @@ -523,6 +523,19 @@ impl HttpClient { Ok(self.header(Header::new("DPR", dpr.header_value()))) } + /// Set bounded `ECT` request Client Hint metadata. + /// + /// The value must be one Network Information effective connection type + /// token (`slow-2g`, `2g`, `3g`, or `4g`) with optional surrounding HTTP + /// optional whitespace. This replaces any existing case-insensitive `ECT` + /// field and only declares request metadata; RTTP does not negotiate + /// content, emit `Accept-CH`, or generate this header automatically. + pub fn ect>(&mut self, value: S) -> error::Result<&mut Self> { + let ect = Ect::parse(value.as_ref()) + .map_err(|parse_error| error::builder_with_message(parse_error.to_string()))?; + Ok(self.header(Header::new("ECT", ect.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 188a5b90..18f7bcb3 100644 --- a/crates/rttp-client/src/lib.rs +++ b/crates/rttp-client/src/lib.rs @@ -132,7 +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::client_hints::{Dpr, DprParseError, Ect, EctParseError}; 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/test_raw_request_capture.rs b/crates/rttp-client/tests/test_raw_request_capture.rs index 41a1957d..33f50944 100644 --- a/crates/rttp-client/tests/test_raw_request_capture.rs +++ b/crates/rttp-client/tests/test_raw_request_capture.rs @@ -5896,6 +5896,57 @@ fn dpr_helper_rejects_malformed_values_before_connecting() { } } +#[test] +fn ect_helper_emits_one_canonical_request_client_hint() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", base_url)) + .header(("ect", "2g")) + .ect("\t4g\t") + .expect("ECT should be accepted") + .emit() + .expect("request should succeed"); + }); + let request = request_text(&request); + + assert_eq!(Some("4g"), header_value(&request, "ECT")); + assert_eq!( + 1, + request + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("ect:")) + .count(), + "typed ECT should replace an existing same-name field" + ); +} + +#[test] +fn ect_helper_rejects_malformed_values_before_connecting() { + let oversized = "4".repeat(64 * 1024 + 1); + for value in [ + "", + "5g", + "4G", + "2g,3g", + "4g\r\nInjected: yes", + oversized.as_str(), + ] { + let request = capture_optional_request(|base_url| { + let error = client() + .get() + .url(format!("{}/asset", base_url)) + .ect(value) + .expect_err("invalid ECT input must be rejected"); + assert!(error.is_builder()); + }); + assert!( + request.is_empty(), + "invalid ECT input must not open a socket" + ); + } +} + #[test] fn raw_dpr_header_remains_available_as_escape_hatch() { let request = capture_request(|base_url| { diff --git a/crates/rttp-protocol/src/client_hints.rs b/crates/rttp-protocol/src/client_hints.rs index 5ae30474..1e5de88e 100644 --- a/crates/rttp-protocol/src/client_hints.rs +++ b/crates/rttp-protocol/src/client_hints.rs @@ -4,6 +4,7 @@ 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; +pub const MAX_ECT_VALUE_BYTES: usize = 64 * 1024; /// Parsed, bounded `DPR` request Client Hint metadata. #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -11,6 +12,12 @@ pub struct Dpr { value: String, } +/// Parsed, bounded `ECT` request Client Hint metadata. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Ect { + value: String, +} + /// Parsed, bounded `Accept-CH` response metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct AcceptCh { @@ -31,6 +38,7 @@ pub struct ClientHintsParseError { pub type AcceptChParseError = ClientHintsParseError; pub type CriticalChParseError = ClientHintsParseError; pub type DprParseError = ClientHintsParseError; +pub type EctParseError = ClientHintsParseError; impl ClientHintsParseError { fn new(message: impl Into) -> Self { @@ -74,6 +82,30 @@ impl Dpr { } } +impl Ect { + 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_ect_singleton(values)?; + let value = value.trim_matches([' ', '\t']); + if !is_ect_token(value) { + return Err(invalid_ect_value()); + } + Ok(Self { + value: value.to_string(), + }) + } + + pub fn header_value(&self) -> String { + self.value.clone() + } +} + impl AcceptCh { pub fn parse(value: impl AsRef) -> Result { Self::parse_values([value.as_ref()]) @@ -243,6 +275,45 @@ fn invalid_dpr_value() -> DprParseError { ClientHintsParseError::new("invalid DPR header value") } +fn parse_ect_singleton<'a, I>(values: I) -> Result<&'a str, EctParseError> +where + I: IntoIterator, +{ + let mut values = values.into_iter(); + let value = values.next().ok_or_else(invalid_ect_value)?; + validate_bounded_ect_value(value)?; + let mut has_duplicate = false; + for value in values { + has_duplicate = true; + validate_bounded_ect_value(value)?; + } + if has_duplicate { + return Err(ClientHintsParseError::new("duplicate ECT header fields")); + } + Ok(value) +} + +fn validate_bounded_ect_value(value: &str) -> Result<(), EctParseError> { + if value.len() > MAX_ECT_VALUE_BYTES { + return Err(ClientHintsParseError::new("ECT header value is too large")); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(ClientHintsParseError::new("invalid ECT control byte")); + } + Ok(()) +} + +fn is_ect_token(value: &str) -> bool { + matches!(value, "slow-2g" | "2g" | "3g" | "4g") +} + +fn invalid_ect_value() -> EctParseError { + ClientHintsParseError::new("invalid ECT 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 bc3c801d..f1f70d55 100644 --- a/crates/rttp-protocol/tests/client_hints.rs +++ b/crates/rttp-protocol/tests/client_hints.rs @@ -1,6 +1,6 @@ use rttp_protocol::client_hints::{ - AcceptCh, CriticalCh, Dpr, MAX_CLIENT_HINT_NAMES, MAX_CLIENT_HINT_VALUE_BYTES, - MAX_DPR_VALUE_BYTES, + AcceptCh, CriticalCh, Dpr, Ect, MAX_CLIENT_HINT_NAMES, MAX_CLIENT_HINT_VALUE_BYTES, + MAX_DPR_VALUE_BYTES, MAX_ECT_VALUE_BYTES, }; #[test] @@ -118,3 +118,50 @@ fn dpr_checks_duplicate_values_against_the_bound() { fn dpr_rejects_non_finite_oversized_digits() { assert!(Dpr::parse("9".repeat(400)).is_err()); } + +#[test] +fn ect_parses_network_information_tokens_and_round_trips() { + for value in ["slow-2g", "2g", "3g", "4g"] { + let ect = Ect::parse(value).expect("valid ECT"); + assert_eq!(value, ect.header_value()); + assert_eq!(ect, Ect::parse(ect.header_value()).expect("ECT roundtrip")); + } +} + +#[test] +fn ect_trims_outer_optional_whitespace() { + let ect = Ect::parse("\t 4g \t").expect("OWS-padded ECT"); + assert_eq!("4g", ect.header_value()); +} + +#[test] +fn ect_rejects_malformed_duplicate_and_empty_values() { + assert!(Ect::parse_values(["4g", "3g"]).is_err()); + assert!(Ect::parse_values([]).is_err()); + + for value in [ + "", + " ", + "5g", + "4G", + "slow_2g", + "2g,3g", + "2g 3g", + "lte", + ] { + assert!(Ect::parse(value).is_err(), "{value:?} must be rejected"); + } +} + +#[test] +fn ect_rejects_oversized_and_control_byte_values() { + assert!(Ect::parse("4".repeat(MAX_ECT_VALUE_BYTES + 1)).is_err()); + assert!(Ect::parse("4g\r\nInjected: yes").is_err()); + assert!(Ect::parse("4g\u{7f}").is_err()); +} + +#[test] +fn ect_checks_duplicate_values_against_the_bound() { + let oversized = "4".repeat(MAX_ECT_VALUE_BYTES + 1); + assert!(Ect::parse_values(["4g", oversized.as_str()]).is_err()); +} diff --git a/crates/rttp-server/src/server/request.rs b/crates/rttp-server/src/server/request.rs index e8844a31..06689e91 100644 --- a/crates/rttp-server/src/server/request.rs +++ b/crates/rttp-server/src/server/request.rs @@ -46,7 +46,10 @@ 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::client_hints::{ + Dpr as HttpDpr, DprParseError as HttpDprParseError, Ect as HttpEct, + EctParseError as HttpEctParseError, +}; pub use rttp_protocol::connection::{ Connection as HttpConnection, ConnectionParseError as HttpConnectionParseError, }; @@ -500,6 +503,16 @@ impl Request { HttpDpr::parse_values(values).map(Some) } + /// Parses received bounded `ECT` request Client Hint metadata without + /// negotiating content or emitting Client Hints. + pub fn ect(&self) -> Result, HttpEctParseError> { + let values: Vec<&str> = self.headers_named("ECT").collect(); + if values.is_empty() { + return Ok(None); + } + HttpEct::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( @@ -2823,6 +2836,21 @@ impl HttpRequest { HttpDpr::parse_values(values).map(Some) } + /// Parses received bounded `ECT` request Client Hint metadata without + /// negotiating content or emitting Client Hints. + pub fn ect(&self) -> Result, HttpEctParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("ECT")) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpEct::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 9cc27d9b..cfb960f2 100644 --- a/crates/rttp-server/tests/metadata_facade.rs +++ b/crates/rttp-server/tests/metadata_facade.rs @@ -24,6 +24,7 @@ use rttp_server::server::{ HttpDocumentPolicyParseError, HttpDocumentPolicyReportOnly, HttpDocumentPolicyReportOnlyParseError, HttpDocumentPolicyReportOnlyValue, HttpDocumentPolicyValue, HttpDpr, HttpDprParseError, HttpEarlyData, HttpEarlyDataParseError, + HttpEct, HttpEctParseError, HttpEntityTag, HttpExpectParseError, HttpExpectations, HttpExpiresParseError, HttpFrom, HttpFromParseError, HttpHost, HttpIdempotencyKey, HttpIdempotencyKeyParseError, HttpIf, HttpIfCondition, HttpIfList, HttpIfModifiedSince, HttpIfModifiedSinceParseError, @@ -1833,6 +1834,37 @@ fn request_facade_parses_dpr_metadata_without_negotiation() { let _: HttpDprParseError = HttpDpr::parse("").expect_err("empty DPR should fail"); } +#[test] +fn request_facade_parses_ect() { + let request = + HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\nECT: \t4g \t\r\n\r\n") + .expect("ECT request should parse"); + let ect: HttpEct = request + .ect() + .expect("ECT should parse") + .expect("ECT should be present"); + assert_eq!("4g", ect.header_value()); + assert_eq!(Some("4g"), request.header("ECT")); + + let absent = HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\n\r\n") + .expect("request without ECT should parse"); + assert_eq!(None, absent.ect().expect("missing ECT should be valid")); + + let malformed = + HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\nECT: 5g\r\n\r\n") + .expect("malformed ECT request should retain raw metadata"); + assert!(malformed.ect().is_err()); + assert_eq!(Some("5g"), malformed.header("ECT")); + + let duplicate = + HttpRequest::parse(b"GET /asset HTTP/1.1\r\nHost: example.test\r\nECT: 4g\r\nECT: 3g\r\n\r\n") + .expect("duplicate ECT request should retain raw metadata"); + let _: HttpEctParseError = duplicate.ect().expect_err("duplicate ECT should fail"); + assert_eq!(Some("4g"), duplicate.header("ECT")); + + let _: HttpEctParseError = HttpEct::parse("").expect_err("empty ECT 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 c1340085..b65b3d21 100644 --- a/crates/rttp/README.md +++ b/crates/rttp/README.md @@ -606,6 +606,18 @@ case-insensitive field. On the server facade, `Request::dpr()` and the raw header available. These helpers only expose metadata: RTTP does not negotiate content, emit `Accept-CH`, or generate Client Hints automatically. +## Bounded ECT request Client Hint metadata + +With the client feature, `HttpClient::ect(value)` validates and emits one +singleton `ECT` request field through `rttp::Ect`, replacing any existing +case-insensitive field. Recognized values are the Network Information +effective connection type tokens `slow-2g`, `2g`, `3g`, and `4g`, with +optional surrounding HTTP optional whitespace. On the server facade, +`Request::ect()` and `HttpRequest::ect()` parse received fields into +`HttpEct`; `header_value()` exposes the preserved token. 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 25cedfed..218d195a 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -58,17 +58,17 @@ pub use rttp_client::{ AIm, AImMember, AImParameter, AImParseError, AcceptDatetime, AcceptDatetimeParseError, Baggage, BaggageMember, BaggageParseError, BaggageProperty, Depth, DepthParseError, Destination, 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, - SecWebSocketKeyParseError, Timeout, TimeoutParseError, TimeoutType, TraceParent, - TraceParentParseError, TraceState, TraceStateMember, TraceStateParseError, UserAgent, - UserAgentMember, UserAgentParseError, Via, ViaMember, ViaParseError, XForwardedFor, - XForwardedForNode, XForwardedForNodeKind, XForwardedForParseError, XForwardedHost, - XForwardedHostParseError, XForwardedProto, XForwardedProtoParseError, + Ect, EctParseError, 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, SecWebSocketKeyParseError, Timeout, + TimeoutParseError, TimeoutType, TraceParent, TraceParentParseError, TraceState, TraceStateMember, + TraceStateParseError, UserAgent, UserAgentMember, UserAgentParseError, Via, ViaMember, + ViaParseError, XForwardedFor, XForwardedForNode, XForwardedForNodeKind, XForwardedForParseError, + XForwardedHost, XForwardedHostParseError, XForwardedProto, XForwardedProtoParseError, }; impl Http {