Skip to content
Merged
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: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/rttp-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions crates/rttp-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<S: AsRef<str>>(&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`
Expand Down
1 change: 1 addition & 0 deletions crates/rttp-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
18 changes: 13 additions & 5 deletions crates/rttp-client/tests/metadata_facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 =
Expand Down
67 changes: 67 additions & 0 deletions crates/rttp-client/tests/test_raw_request_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
9 changes: 9 additions & 0 deletions crates/rttp-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions crates/rttp-protocol/src/client_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<String>) -> Self {
Expand All @@ -40,6 +48,32 @@ impl fmt::Display for ClientHintsParseError {

impl Error for ClientHintsParseError {}

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

pub fn parse_values<'a, I>(values: I) -> Result<Self, DprParseError>
where
I: IntoIterator<Item = &'a str>,
{
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<str>) -> Result<Self, AcceptChParseError> {
Self::parse_values([value.as_ref()])
Expand Down Expand Up @@ -139,6 +173,76 @@ where
Ok(client_hints)
}

fn parse_dpr_singleton<'a, I>(values: I) -> Result<&'a str, DprParseError>
where
I: IntoIterator<Item = &'a str>,
{
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<f64, DprParseError> {
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'))
Expand Down
Loading