Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion crates/rttp-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<S: AsRef<str>>(&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`
Expand Down
2 changes: 1 addition & 1 deletion crates/rttp-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
51 changes: 51 additions & 0 deletions crates/rttp-client/tests/test_raw_request_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
71 changes: 71 additions & 0 deletions crates/rttp-protocol/src/client_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ 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)]
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 {
Expand All @@ -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<String>) -> Self {
Expand Down Expand Up @@ -74,6 +82,30 @@ impl Dpr {
}
}

impl Ect {
pub fn parse(value: impl AsRef<str>) -> Result<Self, EctParseError> {
Self::parse_values([value.as_ref()])
}

pub fn parse_values<'a, I>(values: I) -> Result<Self, EctParseError>
where
I: IntoIterator<Item = &'a str>,
{
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<str>) -> Result<Self, AcceptChParseError> {
Self::parse_values([value.as_ref()])
Expand Down Expand Up @@ -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<Item = &'a str>,
{
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'))
Expand Down
51 changes: 49 additions & 2 deletions crates/rttp-protocol/tests/client_hints.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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());
}
30 changes: 29 additions & 1 deletion crates/rttp-server/src/server/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<Option<HttpEct>, 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<Option<SecFetchSite>, HttpFetchMetadataParseError> {
rttp_protocol::fetch_metadata::parse_optional_value(
Expand Down Expand Up @@ -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<Option<HttpEct>, 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<Option<HttpDnt>, HttpDntParseError> {
Expand Down
32 changes: 32 additions & 0 deletions crates/rttp-server/tests/metadata_facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions crates/rttp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading