From 3a7aec729a77f529424242bfce326eb0ed0a457e Mon Sep 17 00:00:00 2001 From: fewensa Date: Sat, 12 Sep 2026 19:23:37 +0800 Subject: [PATCH] Add typed Early-Data request header support codeon: version: 1 authority: FWN-336 description: |- Implement RFC 8470 Early-Data request metadata as a bounded singleton protocol type. Expose EarlyData through the client, server, and compatibility facades, add client emission and server request accessors, and document the supported value and non-goals. Add focused parser, client emission, server accessor, facade export, roundtrip, malformed-value, duplicate-field, raw-header preservation, and package-list coverage. --- README.md | 14 +++ crates/rttp-client/README.md | 12 +++ crates/rttp-client/src/client.rs | 11 +++ crates/rttp-client/src/lib.rs | 1 + crates/rttp-client/tests/metadata_facade.rs | 29 +++--- .../tests/test_raw_request_capture.rs | 25 +++++ crates/rttp-protocol/README.md | 11 +++ crates/rttp-protocol/src/early_data.rs | 97 +++++++++++++++++++ crates/rttp-protocol/src/lib.rs | 1 + crates/rttp-protocol/tests/early_data.rs | 51 ++++++++++ crates/rttp-protocol/tests/metadata_facade.rs | 5 + crates/rttp-protocol/tests/package_list.rs | 1 + crates/rttp-server/README.md | 14 +++ crates/rttp-server/src/server/request.rs | 28 ++++++ crates/rttp-server/tests/metadata_facade.rs | 52 +++++++++- crates/rttp/README.md | 13 +++ crates/rttp/src/lib.rs | 12 +-- crates/rttp/tests/metadata_facade.rs | 59 +++++++++-- 18 files changed, 404 insertions(+), 32 deletions(-) create mode 100644 crates/rttp-protocol/src/early_data.rs create mode 100644 crates/rttp-protocol/tests/early_data.rs diff --git a/README.md b/README.md index ee12f227..0fc73f6f 100644 --- a/README.md +++ b/README.md @@ -1654,6 +1654,18 @@ field available. These helpers only declare or parse request metadata. RTTP does not infer or enforce consent, tracking, legal, or serving policy. +### Bounded Early-Data request metadata + +`HttpClient::early_data()` emits `Early-Data: 1`. On the server, +`Request::early_data()` and `HttpRequest::early_data()` parse the same bounded +singleton RFC 8470 `1` signal through the shared protocol representation, +returning `Ok(None)` when the field is absent and a parser error for +malformed, unsupported, oversized, duplicate, or control-byte values while +leaving the raw `Early-Data` field available. + +These helpers only declare or parse request metadata. RTTP does not enable +0-RTT transport, decide replay safety, retry, or apply serving policy. + ### Bounded Pragma metadata `rttp-protocol` owns the shared `Pragma` primitive. Client helpers format @@ -1865,6 +1877,7 @@ gain additional HTTP/2 header-block handling. | Set-Cookie | Client `Response::set_cookies()` and server `HttpResponse::with_set_cookie()`/`set_cookies()` share bounded protocol `Set-Cookie` response metadata, preserve multiple field lines and raw headers, redact cookie values from typed debug and errors, and reject invalid typed metadata including duplicate attributes, valued flag attributes, non-standard `SameSite`, invalid `Max-Age`, malformed quoted values, and bounds violations; client `Response::cookies()`/`cookie()` are a legacy compatibility view that only exposes protocol-accepted fields and silently omits invalid `Set-Cookie` fields while raw headers remain available | No cookie jar, persistence, domain/path matching, expiry enforcement, SameSite or partitioning policy, or automatic request `Cookie` emission | | Variant-Vary | Client `Response::variant_vary()` and server `HttpResponse::with_variant_vary()`/`variant_vary()` share bounded RFC 2295 `Variant-Vary` response metadata through the protocol `VariantVary` type while preserving raw headers on accessor errors | No cache-key construction, variant selection, `Alternates`/`TCN`/`Vary` synthesis, transparent content negotiation, or cache behavior | | Sec-GPC | Client `sec_gpc` emits bounded `Sec-GPC: 1` request metadata; server `Request::sec_gpc()` and `HttpRequest::sec_gpc()` parse typed received values while preserving raw headers on errors | No consent inference, tracking-policy enforcement, legal policy, serving policy, retries, or browser state | +| Early-Data | Client `early_data` emits bounded RFC 8470 `Early-Data: 1` request metadata; server `Request::early_data()` and `HttpRequest::early_data()` parse typed received values while preserving raw headers on errors | No 0-RTT transport enablement, replay-safety decision, retry behavior, or server acceptance policy | | Upgrade-Insecure-Requests | Client `upgrade_insecure_requests` emits bounded singleton `Upgrade-Insecure-Requests: 1` request metadata; server `Request::upgrade_insecure_requests()` and `HttpRequest::upgrade_insecure_requests()` parse typed received values while preserving raw headers on errors | No URL rewriting, redirecting, Content-Security-Policy enforcement, HSTS, or automatic scheme selection | | Depth | Client `depth` emits bounded singleton WebDAV `Depth` request metadata through the shared protocol type, replacing an existing same-name field; server `Request::depth()` and `HttpRequest::depth()` parse typed received values while preserving raw headers on errors | No resource traversal, WebDAV method selection, method-policy enforcement, retry, or forwarding policy | | Destination | Client `destination` emits bounded singleton WebDAV `Destination` request metadata through the shared protocol type, replacing an existing same-name field; server `Request::destination()` and `HttpRequest::destination()` parse typed received values while preserving raw headers on errors | No destination resolution, URI normalization, authorization, COPY/MOVE execution, or application resource policy | @@ -3164,6 +3177,7 @@ TLS or async accept loops. | Set-Cookie | `HttpResponse::with_set_cookie` and `HttpResponse::set_cookies` declare or parse bounded protocol `Set-Cookie` response metadata, preserve multiple field lines and raw headers, redact cookie values from typed debug and errors, and reject invalid typed metadata including duplicate attributes, valued flag attributes, non-standard `SameSite`, invalid `Max-Age`, malformed quoted values, and bounds violations | No cookie jar, persistence, domain/path matching, expiry enforcement, SameSite or partitioning policy, or automatic request `Cookie` emission | | Variant-Vary | `HttpVariantVary`, `HttpResponse::with_variant_vary`, and `HttpResponse::variant_vary` declare or parse bounded RFC 2295 `Variant-Vary` response metadata through the shared protocol type, replacing raw duplicates on declaration and preserving raw headers on accessor errors | No cache-key construction, variant selection, `Alternates`/`TCN`/`Vary` synthesis, transparent content negotiation, or cache behavior | | Sec-GPC | `Request::sec_gpc` and `HttpRequest::sec_gpc` parse bounded singleton `Sec-GPC` `1`-signal metadata and preserve raw values on errors | No consent inference, tracking-policy enforcement, legal policy, serving policy, retries, or browser state | +| Early-Data | `Request::early_data` and `HttpRequest::early_data` parse bounded singleton RFC 8470 `Early-Data` `1`-signal metadata and preserve raw values on errors | No 0-RTT transport enablement, replay-safety decision, retry behavior, or server acceptance policy | | Upgrade-Insecure-Requests | `Request::upgrade_insecure_requests` and `HttpRequest::upgrade_insecure_requests` parse bounded singleton `Upgrade-Insecure-Requests` `1`-token metadata and preserve raw values on errors | No URL rewriting, redirecting, Content-Security-Policy enforcement, HSTS, or automatic scheme selection | | Depth | `Request::depth` and `HttpRequest::depth` parse bounded singleton WebDAV `Depth` request metadata through the shared protocol type and preserve raw values on errors | No resource traversal, WebDAV method selection, method-policy enforcement, retry, or forwarding policy | | Destination | `Request::destination` and `HttpRequest::destination` parse bounded singleton WebDAV `Destination` request metadata through the shared protocol type and preserve raw values on errors | No destination resolution, URI normalization, authorization, COPY/MOVE execution, or application resource policy | diff --git a/crates/rttp-client/README.md b/crates/rttp-client/README.md index 8e3d6844..fac08150 100644 --- a/crates/rttp-client/README.md +++ b/crates/rttp-client/README.md @@ -1377,6 +1377,17 @@ This helper only declares request metadata. RTTP does not infer or enforce consent, tracking, legal, or serving policy. Callers that need values outside the helper can retain raw-header control with `header(("Sec-GPC", "..."))`. +## Bounded Early-Data request metadata + +`HttpClient::early_data()` emits `Early-Data: 1` through the shared protocol +`EarlyData` representation. A second call or an existing raw `Early-Data` +field is replaced with the canonical value before a socket is opened. + +This helper only declares RFC 8470 request metadata. RTTP does not enable +0-RTT transport, decide replay safety, retry, or apply server acceptance +policy. Callers that need values outside the helper can retain raw-header +control with `header(("Early-Data", "..."))`. + ## Bounded Sec-Required-Document-Policy request metadata `HttpClient::sec_required_document_policy(value)` validates one WICG Document @@ -1810,6 +1821,7 @@ header-block model. | Referer | `referer` emits one bounded canonical `Referer` request field through the shared protocol type, replacing existing case-insensitive fields; absolute, relative, and scheme-relative URI references are accepted, and raw `header(("Referer", value))` remains available as a fallback | No `Referrer-Policy` enforcement, trust decisions, CSRF protection, redaction, URL canonicalization, or redirect behavior | | User-Agent | `user_agent` emits one bounded canonical `User-Agent` request field through the shared protocol type, replacing existing case-insensitive fields so typed values win over raw headers and the automatic default; absent typed/raw values retain `Mozilla/5.0 rttp/{version}` | No fingerprinting, platform discovery, product policy, global or environment-based defaults, or automatic policy beyond the existing default header | | Sec-GPC | `sec_gpc` emits bounded `Sec-GPC: 1` request metadata through the shared protocol type | No consent inference, tracking-policy enforcement, legal policy, serving policy, retries, or browser state | +| Early-Data | `early_data` emits bounded RFC 8470 `Early-Data: 1` request metadata through the shared protocol type, replacing existing same-name fields before connecting | No 0-RTT transport enablement, replay-safety decision, retry behavior, or server acceptance policy | | Sec-Required-Document-Policy | `sec_required_document_policy` emits bounded WICG Document Policy dictionary request metadata through the shared protocol type, replacing existing same-name fields and rejecting malformed, control, duplicate, or oversized input before connecting | No document-policy enforcement, required-policy comparison against `Document-Policy`, document-load blocking, feature enablement, or report sending | | Upgrade-Insecure-Requests | `upgrade_insecure_requests` emits bounded singleton `Upgrade-Insecure-Requests: 1` request metadata | No URL rewriting, redirecting, Content-Security-Policy enforcement, HSTS, or automatic scheme selection | | Max-Forwards | `max_forwards` emits bounded singleton `Max-Forwards` request metadata through the shared protocol type | No hop decrement, proxy routing, TRACE/OPTIONS selection, retry, or forwarding policy | diff --git a/crates/rttp-client/src/client.rs b/crates/rttp-client/src/client.rs index e4a5c368..6b180613 100644 --- a/crates/rttp-client/src/client.rs +++ b/crates/rttp-client/src/client.rs @@ -24,6 +24,7 @@ use rttp_protocol::cdn_loop::{CdnLoop, MAX_CDN_LOOP_VALUE_BYTES}; use rttp_protocol::depth::Depth; use rttp_protocol::destination::Destination; use rttp_protocol::dnt::Dnt; +use rttp_protocol::early_data::EarlyData; use rttp_protocol::expect::Expect; use rttp_protocol::fetch_metadata::{ SecFetchDest, SecFetchMode, SecFetchSite, SecFetchUser, SecPurpose, @@ -530,6 +531,16 @@ impl HttpClient { Ok(self.header(Header::new("Sec-GPC", sec_gpc.header_value()))) } + /// Set `Early-Data: 1` request metadata. + /// + /// This declares the RFC 8470 early-data signal only; it does not enable + /// 0-RTT transport, decide replay safety, or apply server acceptance policy. + pub fn early_data(&mut self) -> error::Result<&mut Self> { + let early_data = + EarlyData::parse("1").map_err(|error| error::builder_with_message(error.to_string()))?; + Ok(self.header(Header::new("Early-Data", early_data.header_value()))) + } + /// Set bounded `Sec-Required-Document-Policy` request metadata. /// /// The value is validated through the shared protocol diff --git a/crates/rttp-client/src/lib.rs b/crates/rttp-client/src/lib.rs index e89deab2..1d3c325b 100644 --- a/crates/rttp-client/src/lib.rs +++ b/crates/rttp-client/src/lib.rs @@ -137,6 +137,7 @@ pub use rttp_protocol::delta_base::{DeltaBase, DeltaBaseParseError}; pub use rttp_protocol::depth::{Depth, DepthParseError}; pub use rttp_protocol::destination::{Destination, DestinationParseError}; pub use rttp_protocol::dnt::{Dnt, DntParseError}; +pub use rttp_protocol::early_data::{EarlyData, EarlyDataParseError}; pub use rttp_protocol::expect::{Expect, ExpectParseError}; pub use rttp_protocol::fetch_metadata::{ SecFetchDest, SecFetchMode, SecFetchSite, SecFetchUser, SecPurpose, diff --git a/crates/rttp-client/tests/metadata_facade.rs b/crates/rttp-client/tests/metadata_facade.rs index b94628ca..32f1b93d 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -46,18 +46,19 @@ use rttp_client::response::{ use rttp_client::{ AIm, AImMember, AImParameter, AImParseError, AcceptDatetime, AcceptDatetimeParseError, Baggage, BaggageMember, BaggageParseError, BaggageProperty, Depth, DepthParseError, Destination, - DestinationParseError, Dnt, DntParseError, 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, TimeoutType, TraceParent, TraceParentParseError, - TraceState, TraceStateMember, TraceStateParseError, UpgradeInsecureRequests, - UpgradeInsecureRequestsParseError, UserAgent, UserAgentMember, UserAgentParseError, - Via as ClientVia, ViaParseError as ClientViaParseError, XForwardedFor, XForwardedForParseError, - XForwardedHost, XForwardedHostParseError, XForwardedProto, XForwardedProtoParseError, + 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, + SecRequiredDocumentPolicy, SecRequiredDocumentPolicyDirective, + SecRequiredDocumentPolicyParseError, SecRequiredDocumentPolicyValue, SecWebSocketKey, + SecWebSocketKeyParseError, Tcn, TcnDirective, TcnParseError, Timeout, TimeoutParseError, + TimeoutType, TraceParent, TraceParentParseError, TraceState, TraceStateMember, + TraceStateParseError, UpgradeInsecureRequests, UpgradeInsecureRequestsParseError, UserAgent, + UserAgentMember, UserAgentParseError, Via as ClientVia, ViaParseError as ClientViaParseError, + XForwardedFor, XForwardedForParseError, XForwardedHost, XForwardedHostParseError, + XForwardedProto, XForwardedProtoParseError, }; use rttp_test_support as support; @@ -402,6 +403,9 @@ fn response_facade_exports_representative_bounded_metadata_types() { let _: DntParseError = Dnt::parse("on").expect_err("invalid DNT should be rejected"); let sec_gpc = SecGpc::parse("1").expect("Sec-GPC should parse"); let _: SecGpcParseError = SecGpc::parse("0").expect_err("invalid Sec-GPC should be rejected"); + let early_data = EarlyData::parse("1").expect("Early-Data should parse"); + let _: EarlyDataParseError = + EarlyData::parse("0").expect_err("invalid Early-Data should be rejected"); let sec_required_document_policy = SecRequiredDocumentPolicy::parse("oversized-images=2.0, unsized-media=?0, *;report-to=default") .expect("Sec-Required-Document-Policy should parse"); @@ -734,6 +738,7 @@ fn response_facade_exports_representative_bounded_metadata_types() { assert_eq!(fetch_user.header_value(), "?1"); assert_eq!(dnt.header_value(), "1"); assert_eq!(sec_gpc.header_value(), "1"); + assert_eq!(early_data.header_value(), "1"); assert_eq!(sec_required_document_policy.directives().len(), 3); assert_eq!( sec_required_document_policy diff --git a/crates/rttp-client/tests/test_raw_request_capture.rs b/crates/rttp-client/tests/test_raw_request_capture.rs index c09fe905..aa3472df 100644 --- a/crates/rttp-client/tests/test_raw_request_capture.rs +++ b/crates/rttp-client/tests/test_raw_request_capture.rs @@ -5908,6 +5908,31 @@ fn sec_gpc_helper_emits_one_request_signal() { assert_eq!(Some("1"), header_value(&request, "Sec-GPC")); } +#[test] +fn early_data_helper_emits_one_canonical_request_signal() { + let request = capture_request(|base_url| { + client() + .post() + .url(format!("{}/submit", base_url)) + .header(("Early-Data", "0")) + .early_data() + .expect("Early-Data should be accepted") + .emit() + .expect("request should succeed"); + }); + let request = request_text(&request); + + assert_eq!(Some("1"), header_value(&request, "Early-Data")); + assert_eq!( + 1, + request + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("early-data:")) + .count(), + "typed Early-Data should replace an existing same-name field" + ); +} + #[test] fn sec_required_document_policy_helper_emits_canonical_metadata() { let request = capture_request(|base_url| { diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index 24b3697f..1093740f 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -113,6 +113,17 @@ and size validator shared with `IM`. It reports declared request metadata only; it does not select a preferred instance manipulation or apply delta encodings. +## Early-Data + +`early_data` parses singleton RFC 8470 `Early-Data` request metadata. The +only accepted value is the case-sensitive `1` signal with optional surrounding +SP or HTAB. `header_value()` emits the canonical `1` value. Missing values, +unsupported values, duplicate fields, comma-lists, forbidden control bytes, +and values over 64 KiB are errors. + +This type reports declared request metadata only; it does not enable 0-RTT +transport, decide replay safety, retry, or apply server acceptance policy. + ## IM `im` parses one or more RFC 3229 `IM` field values into an ordered list of diff --git a/crates/rttp-protocol/src/early_data.rs b/crates/rttp-protocol/src/early_data.rs new file mode 100644 index 00000000..a5c11257 --- /dev/null +++ b/crates/rttp-protocol/src/early_data.rs @@ -0,0 +1,97 @@ +//! Bounded, policy-free `Early-Data` request metadata parsing. +//! +//! This module validates the RFC 8470 request field value only. Callers +//! decide whether and how to handle replay or 0-RTT policy. + +use std::error::Error; +use std::fmt; + +/// Maximum bytes accepted in an `Early-Data` field value. +pub const MAX_EARLY_DATA_VALUE_BYTES: usize = 64 * 1024; + +/// Parsed, bounded RFC 8470 `Early-Data` request metadata. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub struct EarlyData; + +impl EarlyData { + 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, + { + parse_singleton(values) + } + + pub fn header_value(&self) -> &'static str { + "1" + } +} + +/// An error returned when `Early-Data` metadata is malformed or exceeds bounds. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EarlyDataParseError { + message: String, +} + +impl EarlyDataParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for EarlyDataParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for EarlyDataParseError {} + +fn parse_singleton<'a, I>(values: I) -> Result +where + I: IntoIterator, +{ + let mut values = values.into_iter(); + let value = values.next().ok_or_else(invalid_value)?; + validate_value(value)?; + let mut has_duplicate = false; + for value in values { + has_duplicate = true; + validate_value(value)?; + } + if has_duplicate { + return Err(EarlyDataParseError::new( + "duplicate Early-Data header fields", + )); + } + + if value.trim_matches([' ', '\t']) != "1" { + return Err(invalid_value()); + } + + Ok(EarlyData) +} + +fn validate_value(value: &str) -> Result<(), EarlyDataParseError> { + if value.len() > MAX_EARLY_DATA_VALUE_BYTES { + return Err(EarlyDataParseError::new( + "Early-Data header value is too large", + )); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(EarlyDataParseError::new("invalid Early-Data control byte")); + } + Ok(()) +} + +fn invalid_value() -> EarlyDataParseError { + EarlyDataParseError::new("invalid Early-Data header value") +} diff --git a/crates/rttp-protocol/src/lib.rs b/crates/rttp-protocol/src/lib.rs index fa151ebe..4ecc481c 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -66,6 +66,7 @@ pub mod digest; pub mod dnt; pub mod document_policy; pub mod document_policy_report_only; +pub mod early_data; pub mod entity_tag; pub mod expect; pub mod fetch_metadata; diff --git a/crates/rttp-protocol/tests/early_data.rs b/crates/rttp-protocol/tests/early_data.rs new file mode 100644 index 00000000..445da30b --- /dev/null +++ b/crates/rttp-protocol/tests/early_data.rs @@ -0,0 +1,51 @@ +use rttp_protocol::early_data::{EarlyData, MAX_EARLY_DATA_VALUE_BYTES}; + +#[test] +fn early_data_parses_defined_request_signal() { + let metadata = EarlyData::parse("1").expect("valid Early-Data"); + + assert_eq!("1", metadata.header_value()); +} + +#[test] +fn early_data_parse_values_accepts_single_ows_padded_field() { + let metadata = EarlyData::parse_values(["\t1 "]).expect("single Early-Data field"); + + assert_eq!("1", metadata.header_value()); +} + +#[test] +fn early_data_rejects_malformed_and_unsupported_values() { + for value in ["", " ", "\t", "0", "2", "true", "?1", "1, 1", "1;foo=bar"] { + assert!( + EarlyData::parse(value).is_err(), + "{value:?} should be rejected" + ); + } +} + +#[test] +fn early_data_rejects_control_bytes() { + for value in ["1\r", "1\n", "1\u{7f}"] { + assert!( + EarlyData::parse(value).is_err(), + "{value:?} should be rejected" + ); + } +} + +#[test] +fn early_data_rejects_duplicate_header_fields() { + assert!(EarlyData::parse_values(["1", "1"]).is_err()); +} + +#[test] +fn early_data_rejects_empty_value_lists() { + assert!(EarlyData::parse_values([] as [&str; 0]).is_err()); +} + +#[test] +fn early_data_enforces_value_bounds() { + let oversized = "1".repeat(MAX_EARLY_DATA_VALUE_BYTES + 1); + assert!(EarlyData::parse(oversized).is_err()); +} diff --git a/crates/rttp-protocol/tests/metadata_facade.rs b/crates/rttp-protocol/tests/metadata_facade.rs index 506d35a0..ffffd9b1 100644 --- a/crates/rttp-protocol/tests/metadata_facade.rs +++ b/crates/rttp-protocol/tests/metadata_facade.rs @@ -42,6 +42,7 @@ use rttp_protocol::document_policy::{DocumentPolicy, DocumentPolicyParseError}; use rttp_protocol::document_policy_report_only::{ DocumentPolicyReportOnly, DocumentPolicyReportOnlyParseError, }; +use rttp_protocol::early_data::{EarlyData, EarlyDataParseError}; use rttp_protocol::entity_tag::{EntityTag, IfMatch}; use rttp_protocol::expect::Expect; use rttp_protocol::fetch_metadata::{ @@ -147,6 +148,9 @@ fn protocol_exports_representative_bounded_metadata_types() { let _: AcceptParseError = Accept::parse("*/json").expect_err("invalid Accept should fail"); let dnt = Dnt::parse("1").expect("DNT should parse"); let sec_gpc = SecGpc::parse("1").expect("Sec-GPC should parse"); + let early_data = EarlyData::parse("1").expect("Early-Data should parse"); + let _: EarlyDataParseError = + EarlyData::parse("0").expect_err("invalid Early-Data should be rejected"); let upgrade_insecure_requests = UpgradeInsecureRequests::parse("1").expect("Upgrade-Insecure-Requests should parse"); let critical_ch = CriticalCh::parse("Sec-CH-UA").expect("Critical-CH should parse"); @@ -400,6 +404,7 @@ fn protocol_exports_representative_bounded_metadata_types() { assert_eq!(Some("1"), media_range.parameter("level")); assert_eq!(dnt.header_value(), "1"); assert_eq!(sec_gpc.header_value(), "1"); + assert_eq!(early_data.header_value(), "1"); assert_eq!(upgrade_insecure_requests.header_value(), "1"); assert_eq!(critical_ch.client_hints(), ["Sec-CH-UA"]); assert_eq!(entity_tag.opaque_tag(), "revision-42"); diff --git a/crates/rttp-protocol/tests/package_list.rs b/crates/rttp-protocol/tests/package_list.rs index bcbf0fc3..9bbe718d 100644 --- a/crates/rttp-protocol/tests/package_list.rs +++ b/crates/rttp-protocol/tests/package_list.rs @@ -68,6 +68,7 @@ fn package_includes_protocol_metadata_facade_test() { "tests/origin_trial.rs", "tests/speculation_rules.rs", "tests/sec_gpc.rs", + "tests/early_data.rs", "tests/sec_websocket_accept.rs", "tests/sec_websocket_extensions.rs", "tests/sec_websocket_key.rs", diff --git a/crates/rttp-server/README.md b/crates/rttp-server/README.md index 271d11f5..5d21e307 100644 --- a/crates/rttp-server/README.md +++ b/crates/rttp-server/README.md @@ -1046,6 +1046,20 @@ original raw field. These helpers parse request metadata only. They do not infer or enforce consent, tracking, legal, or serving policy. +## Early-Data request metadata + +Handlers can call `Request::early_data()` and `HttpRequest::early_data()` to +observe bounded typed RFC 8470 `Early-Data` request metadata through the +shared protocol `HttpEarlyData` representation. Absent fields return +`Ok(None)`. The recognized value is the case-sensitive `1` signal with +optional surrounding SP or HTAB. Malformed, unsupported, oversized, +duplicate, or control-byte values return `HttpEarlyDataParseError` while +`Request::header()` and `HttpRequest::header()` continue to expose the +original raw field. + +These helpers parse request metadata only. They do not enable 0-RTT +transport, decide replay safety, retry, or apply serving policy. + ## Sec-Required-Document-Policy request metadata Handlers can call `Request::sec_required_document_policy()` and diff --git a/crates/rttp-server/src/server/request.rs b/crates/rttp-server/src/server/request.rs index d94fe48f..88ca4c74 100644 --- a/crates/rttp-server/src/server/request.rs +++ b/crates/rttp-server/src/server/request.rs @@ -56,6 +56,9 @@ pub use rttp_protocol::destination::{ Destination as HttpDestination, DestinationParseError as HttpDestinationParseError, }; pub use rttp_protocol::dnt::{Dnt as HttpDnt, DntParseError as HttpDntParseError}; +pub use rttp_protocol::early_data::{ + EarlyData as HttpEarlyData, EarlyDataParseError as HttpEarlyDataParseError, +}; pub use rttp_protocol::entity_tag::{ EntityTag as HttpEntityTag, EntityTagParseError as HttpEntityTagParseError, }; @@ -603,6 +606,16 @@ impl Request { HttpSecGpc::parse_values(values).map(Some) } + /// Parses received RFC 8470 `Early-Data` metadata without applying + /// replay, 0-RTT transport, or serving policy. + pub fn early_data(&self) -> Result, HttpEarlyDataParseError> { + let values: Vec<&str> = self.headers_named("Early-Data").collect(); + if values.is_empty() { + return Ok(None); + } + HttpEarlyData::parse_values(values).map(Some) + } + /// Parses received `Sec-Required-Document-Policy` request metadata without /// enforcing document policy, comparing values against `Document-Policy`, /// blocking document loads, or sending reports. @@ -2831,6 +2844,21 @@ impl HttpRequest { HttpSecGpc::parse_values(values).map(Some) } + /// Parses received RFC 8470 `Early-Data` metadata without applying + /// replay, 0-RTT transport, or serving policy. + pub fn early_data(&self) -> Result, HttpEarlyDataParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("Early-Data")) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpEarlyData::parse_values(values).map(Some) + } + /// Parses received `Sec-Required-Document-Policy` request metadata without /// enforcing document policy, comparing values against `Document-Policy`, /// blocking document loads, or sending reports. diff --git a/crates/rttp-server/tests/metadata_facade.rs b/crates/rttp-server/tests/metadata_facade.rs index 0a4112c9..33120602 100644 --- a/crates/rttp-server/tests/metadata_facade.rs +++ b/crates/rttp-server/tests/metadata_facade.rs @@ -23,11 +23,11 @@ use rttp_server::server::{ HttpDepthParseError, HttpDnt, HttpDntParseError, HttpDocumentPolicy, HttpDocumentPolicyDirective, HttpDocumentPolicyParseError, HttpDocumentPolicyReportOnly, HttpDocumentPolicyReportOnlyParseError, HttpDocumentPolicyReportOnlyValue, - HttpDocumentPolicyValue, HttpEntityTag, HttpExpectParseError, HttpExpectations, - HttpExpiresParseError, HttpFrom, HttpFromParseError, HttpHost, HttpIdempotencyKey, - HttpIdempotencyKeyParseError, HttpIf, HttpIfCondition, HttpIfList, HttpIfModifiedSince, - HttpIfModifiedSinceParseError, HttpIfParseError, HttpIfPredicate, HttpIfResourceTag, - HttpIfScheduleTagMatch, HttpIfScheduleTagMatchParseError, HttpIfStateToken, + 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, HttpMaxForwardsParseError, HttpMementoDatetime, HttpMementoDatetimeParseError, HttpNegotiate, @@ -264,6 +264,8 @@ fn server_facade_exports_representative_bounded_metadata_types() { HttpUserAgent::parse("product/"); let sec_gpc: HttpSecGpc = HttpSecGpc::parse("1").expect("Sec-GPC should parse"); let sec_gpc_error: Result = HttpSecGpc::parse("0"); + let early_data: HttpEarlyData = HttpEarlyData::parse("1").expect("Early-Data should parse"); + let early_data_error: Result = HttpEarlyData::parse("0"); let sec_required_document_policy: HttpSecRequiredDocumentPolicy = HttpSecRequiredDocumentPolicy::parse( "oversized-images=2.0, unsized-media=?0, *;report-to=default", @@ -604,6 +606,8 @@ fn server_facade_exports_representative_bounded_metadata_types() { assert!(user_agent_error.is_err()); assert_eq!(sec_gpc.header_value(), "1"); assert!(sec_gpc_error.is_err()); + assert_eq!(early_data.header_value(), "1"); + assert!(early_data_error.is_err()); assert_eq!(sec_required_document_policy.directives().len(), 3); assert_eq!( sec_required_document_policy @@ -2648,6 +2652,44 @@ fn request_facade_parses_accept_datetime_request_metadata() { HttpAcceptDatetime::parse("").expect_err("empty Accept-Datetime should be rejected"); } +#[test] +fn request_facade_parses_early_data_request_metadata() { + let request = + HttpRequest::parse(b"POST /charge HTTP/1.1\r\nHost: example.test\r\nEarly-Data: 1\r\n\r\n") + .expect("request should parse"); + + let early_data: HttpEarlyData = request + .early_data() + .expect("Early-Data should parse") + .expect("Early-Data should be present"); + assert_eq!("1", early_data.header_value()); + assert_eq!( + Some("1"), + request.header("Early-Data"), + "the raw field must remain available" + ); + + let absent = HttpRequest::parse(b"POST /charge HTTP/1.1\r\nHost: example.test\r\n\r\n") + .expect("request should parse"); + assert_eq!(None, absent.early_data().expect("absent value is valid")); + + let malformed = + HttpRequest::parse(b"POST /charge HTTP/1.1\r\nHost: example.test\r\nEarly-Data: 0\r\n\r\n") + .expect("request should parse"); + assert!(malformed.early_data().is_err()); + assert_eq!( + Some("0"), + malformed.header("Early-Data"), + "raw headers must remain inspectable after a parse error" + ); + + let duplicate = HttpRequest::parse( + b"POST /charge HTTP/1.1\r\nHost: example.test\r\nEarly-Data: 1\r\nearly-data: 1\r\n\r\n", + ) + .expect("request should parse"); + assert!(duplicate.early_data().is_err()); +} + #[test] fn request_facade_parses_want_repr_digest_metadata() { let request = HttpRequest::parse( diff --git a/crates/rttp/README.md b/crates/rttp/README.md index efdea49e..c977f2aa 100644 --- a/crates/rttp/README.md +++ b/crates/rttp/README.md @@ -1121,6 +1121,18 @@ available. These helpers only declare or parse request metadata. RTTP does not infer or enforce consent, tracking, legal, or serving policy. +## Bounded Early-Data request metadata + +`HttpClient::early_data()` emits `Early-Data: 1` through the shared protocol +representation. Server-side `Request::early_data()` and +`HttpRequest::early_data()` parse the same bounded singleton `1` signal and +return `Ok(None)` when the field is absent. Malformed, unsupported, +oversized, duplicate, or control-byte values return a parser error while raw +request headers remain available. + +These helpers only declare or parse RFC 8470 request metadata. RTTP does not +enable 0-RTT transport, decide replay safety, retry, or apply serving policy. + ## Bounded Sec-Required-Document-Policy request metadata `HttpClient::sec_required_document_policy(value)` emits bounded @@ -1805,6 +1817,7 @@ scheduling, or async accept loops. | Metadata HTTP/1.1 interoperability matrix | Workspace integration tests cover live HTTP/1.1 client/server/facade roundtrips for `From`, `Referer`, `Accept-Patch`, `Accept-Post`, and `RateLimit-*`, including canonical wire values, ordered typed values, raw escape hatches, absence as `Ok(None)`, malformed peer input with raw preservation, pre-connect builder rejection, and sync/async client parity | No identity, referrer, method-negotiation, or quota policy | | Accept-Encoding | `HttpRequestAcceptEncodings`, `Request::accept_encoding`, and `HttpRequest::accept_encoding` parse bounded `Accept-Encoding` request metadata through the shared `rttp-protocol` type | No compression, decompression, content negotiation, retries, or transport changes | | Sec-GPC | `HttpClient::sec_gpc`, `Request::sec_gpc`, and `HttpRequest::sec_gpc` share the bounded protocol `Sec-GPC` `1`-signal representation and preserve raw values on errors | No consent inference, tracking-policy enforcement, legal policy, serving policy, retries, or browser state | +| Early-Data | `HttpClient::early_data`, `Request::early_data`, and `HttpRequest::early_data` share the bounded RFC 8470 `Early-Data` `1`-signal representation and preserve raw values on errors | No 0-RTT transport enablement, replay-safety decision, retry behavior, or server acceptance policy | | Sec-Required-Document-Policy | `HttpClient::sec_required_document_policy`, `Request::sec_required_document_policy`, and `HttpRequest::sec_required_document_policy` share the bounded Document Policy Structured Fields request metadata type, replace existing same-name fields on emission, and preserve raw values on errors | No document-policy enforcement, required-policy comparison against `Document-Policy`, document-load blocking, feature enablement, or report sending | | Pragma | `HttpClient::pragma`/`pragma_no_cache`, `Request::pragma`, `HttpRequest::pragma`, `HttpResponse::with_pragma`, and `HttpResponse::pragma` share the bounded protocol `Pragma` representation across client construction, server access, server response declaration, and client response access, combining fields in wire order and preserving raw headers on errors | No translation into `Cache-Control`, cache storage, freshness checks, revalidation, or cache/intermediary policy | | Content-Location | `HttpResponse::with_content_location` declares one bounded singleton `Content-Location` header, and `HttpResponse::content_location` parses attached singleton response metadata while preserving raw headers | No redirect behavior, cache variant selection, representation replacement, retry/replay, route generation, or status-policy behavior | diff --git a/crates/rttp/src/lib.rs b/crates/rttp/src/lib.rs index ed983881..0759407a 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -57,12 +57,12 @@ 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, 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, + DestinationParseError, Dnt, DntParseError, 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, diff --git a/crates/rttp/tests/metadata_facade.rs b/crates/rttp/tests/metadata_facade.rs index dfb27ac8..95ff21b7 100644 --- a/crates/rttp/tests/metadata_facade.rs +++ b/crates/rttp/tests/metadata_facade.rs @@ -12,15 +12,15 @@ use rttp::server::{ HttpCrossOriginOpenerPolicy, HttpCrossOriginOpenerPolicyReportOnly, HttpCrossOriginResourcePolicy, HttpDeltaBase, HttpDeltaBaseParseError, HttpDeprecation, HttpDeprecationParseError, HttpDepth, HttpDepthParseError, HttpDestination, - HttpDestinationParseError, HttpDnt, HttpDntParseError, HttpEntityTag, HttpExpectations, HttpFrom, - HttpFromParseError, HttpIdempotencyKey, HttpIdempotencyKeyParseError, HttpIf, - HttpIfModifiedSince, HttpIfScheduleTagMatch, HttpIfScheduleTagMatchParseError, - HttpIfUnmodifiedSince, HttpLockToken, HttpLockTokenParseError, HttpMaxForwards, HttpMediaType, - HttpMediaTypeParameter, HttpMementoDatetime, HttpMementoDatetimeParseError, HttpNegotiate, - HttpNegotiateDirective, HttpNegotiateParseError, HttpNel, HttpOriginTrialParseError, - HttpOriginTrials, HttpOverwrite, HttpPermissionsPolicy, HttpPermissionsPolicyParseError, - HttpPragma, HttpPragmaParseError, HttpProxyAuthorization, HttpProxyStatus, - HttpProxyStatusParseError, HttpRateLimitLimit, HttpRateLimitLimitItem, + HttpDestinationParseError, HttpDnt, HttpDntParseError, HttpEarlyData, HttpEarlyDataParseError, + HttpEntityTag, HttpExpectations, HttpFrom, HttpFromParseError, HttpIdempotencyKey, + HttpIdempotencyKeyParseError, HttpIf, HttpIfModifiedSince, HttpIfScheduleTagMatch, + HttpIfScheduleTagMatchParseError, HttpIfUnmodifiedSince, HttpLockToken, HttpLockTokenParseError, + HttpMaxForwards, HttpMediaType, HttpMediaTypeParameter, HttpMementoDatetime, + HttpMementoDatetimeParseError, HttpNegotiate, HttpNegotiateDirective, HttpNegotiateParseError, + HttpNel, HttpOriginTrialParseError, HttpOriginTrials, HttpOverwrite, HttpPermissionsPolicy, + HttpPermissionsPolicyParseError, HttpPragma, HttpPragmaParseError, HttpProxyAuthorization, + HttpProxyStatus, HttpProxyStatusParseError, HttpRateLimitLimit, HttpRateLimitLimitItem, HttpRateLimitLimitParseError, HttpRateLimitParseError, HttpRateLimitRemaining, HttpRateLimitRemainingParseError, HttpRateLimitReset, HttpRateLimitResetParseError, HttpReferer, HttpRefererParseError, HttpRequest, HttpRequestAcceptCharsets, HttpResponse, HttpSameSite, @@ -1040,6 +1040,8 @@ fn compatibility_facade_roundtrips_representation_metadata_matrix() { .expect("Want-Repr-Digest preference should be accepted") .sec_gpc() .expect("Sec-GPC should be accepted") + .early_data() + .expect("Early-Data should be accepted") .emit() .expect("client request should complete"); let captured_request = handle @@ -1069,6 +1071,10 @@ fn compatibility_facade_roundtrips_representation_metadata_matrix() { header_value(&captured_request_text, "Content-Language") ); assert_eq!(Some("1"), header_value(&captured_request_text, "Sec-GPC")); + assert_eq!( + Some("1"), + header_value(&captured_request_text, "Early-Data") + ); let server_request = rttp::server::HttpRequest::parse(&captured_request).expect("server request should parse"); @@ -1090,6 +1096,14 @@ fn compatibility_facade_roundtrips_representation_metadata_matrix() { .header_value(), "1" ); + assert_eq!( + server_request + .early_data() + .expect("server Early-Data should parse") + .expect("server Early-Data should be present") + .header_value(), + "1" + ); assert_eq!( server_request .content_type() @@ -1266,6 +1280,29 @@ fn compatibility_facade_rejects_invalid_sec_gpc_request_metadata() { ); } +#[test] +#[cfg(feature = "client")] +fn compatibility_facade_rejects_invalid_early_data_request_metadata() { + let malformed = rttp::server::HttpRequest::parse( + b"POST /charge HTTP/1.1\r\nHost: example.test\r\nEarly-Data: 0\r\n\r\n", + ) + .expect("malformed Early-Data request should still parse"); + assert!( + malformed.early_data().is_err(), + "malformed Early-Data values must fail closed" + ); + assert_eq!(Some("0"), malformed.header("Early-Data")); + + let duplicate = rttp::server::HttpRequest::parse( + b"POST /charge HTTP/1.1\r\nHost: example.test\r\nEarly-Data: 1\r\nearly-data: 1\r\n\r\n", + ) + .expect("duplicate Early-Data request should still parse"); + assert!( + duplicate.early_data().is_err(), + "duplicate Early-Data fields must fail closed" + ); +} + #[test] #[cfg(feature = "client")] fn compatibility_facade_roundtrips_sec_required_document_policy_request_metadata() { @@ -2108,6 +2145,9 @@ fn compatibility_facade_keeps_server_metadata_in_the_server_module() { let sec_gpc: HttpSecGpc = HttpSecGpc::parse("1").expect("Sec-GPC should parse"); let _: HttpSecGpcParseError = HttpSecGpc::parse("0").expect_err("invalid Sec-GPC should be rejected"); + let early_data: HttpEarlyData = HttpEarlyData::parse("1").expect("Early-Data should parse"); + let _: HttpEarlyDataParseError = + HttpEarlyData::parse("0").expect_err("invalid Early-Data should be rejected"); let sec_required_document_policy: HttpSecRequiredDocumentPolicy = HttpSecRequiredDocumentPolicy::parse( "oversized-images=2.0, unsized-media=?0, *;report-to=default", @@ -2309,6 +2349,7 @@ fn compatibility_facade_keeps_server_metadata_in_the_server_module() { assert_eq!(save_data.header_value(), "on"); assert_eq!(dnt.header_value(), "1"); assert!(dnt_error.is_err()); + assert_eq!(early_data.header_value(), "1"); assert_eq!( referer.header_value(), "https://shop.example/checkout?step=pay"