@@ -7,10 +7,10 @@ use bytes::Bytes;
77use futures:: StreamExt ;
88use headers_accept:: Accept ;
99use lh_types:: { BeaconBlock , ForkName } ;
10- use mediatype:: { MediaType , ReadParams } ;
10+ use mediatype:: { MediaType , ReadParams , names } ;
1111use reqwest:: {
1212 Response ,
13- header:: { ACCEPT , CONTENT_TYPE , HeaderMap } ,
13+ header:: { ACCEPT , CONTENT_TYPE , HeaderMap , ToStrError } ,
1414} ;
1515use thiserror:: Error ;
1616
@@ -37,6 +37,18 @@ pub enum ResponseReadError {
3737 NonSuccess { status_code : u16 , error_msg : String , request_url : String } ,
3838}
3939
40+ #[ derive( Debug , Error ) ]
41+ pub enum AcceptedEncodingsError {
42+ #[ error( "invalid header string: {0}" ) ]
43+ InvalidString ( #[ from] ToStrError ) ,
44+
45+ #[ error( "invalid accept header" ) ]
46+ InvalidAccept ,
47+
48+ #[ error( "unsupported accept type" ) ]
49+ UnsupportedAcceptType ,
50+ }
51+
4052#[ cfg( feature = "testing-flags" ) ]
4153thread_local ! {
4254 static IGNORE_CONTENT_LENGTH : Cell <bool > = const { Cell :: new( false ) } ;
@@ -108,7 +120,7 @@ pub async fn read_chunked_body_with_max(
108120/// Reads an HTTP response body with a size limit, erroring on non-success
109121/// status or read failure.
110122pub async fn safe_read_http_response (
111- response : reqwest :: Response ,
123+ response : Response ,
112124 max_size : usize ,
113125) -> Result < Vec < u8 > , ResponseReadError > {
114126 let status_code = response. status ( ) ;
@@ -140,12 +152,6 @@ pub fn get_user_agent_with_version(req_headers: &HeaderMap) -> eyre::Result<Head
140152 Ok ( HeaderValue :: from_str ( & format ! ( "commit-boost/{HEADER_VERSION_VALUE} {ua}" ) ) ?)
141153}
142154
143- /// Deterministic outbound `Accept` header used when PBS asks a relay for a
144- /// response it will itself decode (validation mode On/Extra). SSZ is preferred
145- /// for wire efficiency. Emitted verbatim so packet captures and support
146- /// tickets are reproducible.
147- pub const OUTBOUND_ACCEPT : & str = "application/octet-stream;q=1.0,application/json;q=0.9" ;
148-
149155/// Default encoding used when the caller does not express a format
150156/// preference. This covers both `Accept: */*` (see `get_accept_types`) and
151157/// a missing Content-Type header on inbound or relay responses (see
@@ -190,24 +196,32 @@ impl IntoIterator for AcceptedEncodings {
190196/// Parse the ACCEPT header into a q-value ordered [`AcceptedEncodings`]
191197/// (highest preference first, deduplicated), defaulting to the request's
192198/// Content-Type when no Accept header is present. Returns an error only if
193- /// every media type in the header is malformed or unsupported. Supports
194- /// requests with multiple ACCEPT headers or headers with multiple media
195- /// types. `q=0` entries are treated as explicit rejections per RFC 7231
199+ /// every media type in the header is malformed or unsupported.
200+ /// Multiple Accept header fields are combined before parsing so q-value
201+ /// ordering is applied globally across all media ranges.
202+ /// `q=0` entries are treated as explicit rejections per RFC 7231
196203/// §5.3.1 and are skipped.
197- ///
198204/// The returned order honors the RFC 9110 §12.5.1 precedence rules already
199205/// applied by `headers_accept::Accept::media_types()` (specificity, then
200206/// q-value, then original order).
201- pub fn get_accept_types ( req_headers : & HeaderMap ) -> eyre:: Result < AcceptedEncodings > {
207+ pub fn get_accept_types (
208+ req_headers : & HeaderMap ,
209+ ) -> Result < AcceptedEncodings , AcceptedEncodingsError > {
202210 // Only two supported media types, so the ordered set is at most two
203211 // entries: primary + optional fallback.
204212 let mut primary: Option < EncodingType > = None ;
205213 let mut fallback: Option < EncodingType > = None ;
206214 let mut saw_any = false ;
207215 let mut had_supported = false ;
216+ let mut accept_values = Vec :: new ( ) ;
217+
208218 for header in req_headers. get_all ( ACCEPT ) . iter ( ) {
209- let accept = Accept :: from_str ( header. to_str ( ) ?)
210- . map_err ( |e| eyre:: eyre!( "invalid accept header: {e}" ) ) ?;
219+ accept_values. push ( header. to_str ( ) ?) ;
220+ }
221+ if !accept_values. is_empty ( ) {
222+ let accept_str = accept_values. join ( "," ) ;
223+ let accept =
224+ Accept :: from_str ( & accept_str) . map_err ( |_| AcceptedEncodingsError :: InvalidAccept ) ?;
211225 for mt in accept. media_types ( ) {
212226 saw_any = true ;
213227
@@ -221,13 +235,7 @@ pub fn get_accept_types(req_headers: &HeaderMap) -> eyre::Result<AcceptedEncodin
221235 continue ;
222236 }
223237
224- let parsed = match mt. essence ( ) . to_string ( ) . as_str ( ) {
225- APPLICATION_OCTET_STREAM => Some ( EncodingType :: Ssz ) ,
226- APPLICATION_JSON => Some ( EncodingType :: Json ) ,
227- WILDCARD => Some ( NO_PREFERENCE_DEFAULT ) ,
228- _ => None ,
229- } ;
230- if let Some ( enc) = parsed {
238+ if let Some ( enc) = essence_encoding ( & mt. essence ( ) ) {
231239 had_supported = true ;
232240 match primary {
233241 None => primary = Some ( enc) ,
@@ -243,14 +251,30 @@ pub fn get_accept_types(req_headers: &HeaderMap) -> eyre::Result<AcceptedEncodin
243251 }
244252
245253 if saw_any && !had_supported {
246- eyre :: bail! ( "unsupported accept type" ) ;
254+ return Err ( AcceptedEncodingsError :: UnsupportedAcceptType )
247255 }
248256
249257 // No accept header (or only q=0 rejections): fall back to the request
250258 // Content-Type, which mirrors the historical behavior.
251259 Ok ( AcceptedEncodings :: single ( get_content_type ( req_headers) ) )
252260}
253261
262+ fn essence_encoding ( mt : & MediaType ) -> Option < EncodingType > {
263+ if mt. suffix . is_some ( ) {
264+ return None ;
265+ }
266+
267+ match ( ) {
268+ _ if mt. ty == names:: _STAR && mt. subty == names:: _STAR => Some ( NO_PREFERENCE_DEFAULT ) ,
269+ _ if mt. ty == names:: APPLICATION && mt. subty == names:: OCTET_STREAM => {
270+ Some ( EncodingType :: Ssz )
271+ }
272+ _ if mt. ty == names:: APPLICATION && mt. subty == names:: JSON => Some ( EncodingType :: Json ) ,
273+ _ if mt. ty == names:: APPLICATION && mt. subty == names:: _STAR => Some ( NO_PREFERENCE_DEFAULT ) ,
274+ _ => None ,
275+ }
276+ }
277+
254278/// Compute the q-value for the `index`-th preferred encoding when building an
255279/// outbound `Accept` header. The first entry gets q=1.0, each subsequent entry
256280/// decreases by 0.1, and the value is clamped to a minimum of 0.1 so we never
@@ -269,17 +293,19 @@ fn format_accept_entry(enc: EncodingType, q: f32) -> String {
269293 format ! ( "{};q={:.1}" , enc. content_type( ) , q)
270294}
271295
272- /// Build an `Accept` header string that mirrors the caller's preference order
296+ /// Build an `Accept` header that mirrors the caller's preference order
273297/// so the relay sees the same priority the beacon node asked us for. Each
274298/// subsequent entry receives a q-value 0.1 lower than the previous one,
275- /// starting at 1.0.
276- pub fn build_outbound_accept ( preferred : AcceptedEncodings ) -> String {
277- preferred
299+ /// starting at 1.0. Returns a ready-to-use `HeaderValue` — the output is
300+ /// always valid ASCII, so infallible.
301+ pub fn build_outbound_accept ( preferred : AcceptedEncodings ) -> HeaderValue {
302+ let s = preferred
278303 . iter ( )
279304 . enumerate ( )
280305 . map ( |( i, enc) | format_accept_entry ( enc, accept_q_value_for_index ( i) ) )
281306 . collect :: < Vec < _ > > ( )
282- . join ( "," )
307+ . join ( "," ) ;
308+ HeaderValue :: from_str ( & s) . expect ( "build_outbound_accept produces valid header value" )
283309}
284310
285311pub fn get_content_type ( req_headers : & HeaderMap ) -> EncodingType {
@@ -345,11 +371,7 @@ impl FromStr for EncodingType {
345371 // (e.g. `application/json; charset=utf-8`). Compare essence only.
346372 let parsed =
347373 MediaType :: parse ( value) . map_err ( |e| format ! ( "invalid content type {value}: {e}" ) ) ?;
348- match parsed. essence ( ) . to_string ( ) . to_ascii_lowercase ( ) . as_str ( ) {
349- APPLICATION_JSON => Ok ( EncodingType :: Json ) ,
350- APPLICATION_OCTET_STREAM => Ok ( EncodingType :: Ssz ) ,
351- _ => Err ( format ! ( "unsupported encoding type: {value}" ) ) ,
352- }
374+ essence_encoding ( & parsed) . ok_or_else ( || format ! ( "unsupported encoding type: {value}" ) )
353375 }
354376}
355377
@@ -430,7 +452,7 @@ mod test {
430452
431453 use super :: {
432454 APPLICATION_JSON , APPLICATION_OCTET_STREAM , AcceptedEncodings , BodyDeserializeError ,
433- CONSENSUS_VERSION_HEADER , EncodingType , NO_PREFERENCE_DEFAULT , OUTBOUND_ACCEPT , WILDCARD ,
455+ CONSENSUS_VERSION_HEADER , EncodingType , NO_PREFERENCE_DEFAULT , WILDCARD ,
434456 accept_q_value_for_index, build_outbound_accept, deserialize_body, format_accept_entry,
435457 get_accept_types, get_consensus_version_header, get_content_type,
436458 parse_response_encoding_and_fork,
@@ -525,6 +547,15 @@ mod test {
525547 assert ! ( result. is_err( ) ) ;
526548 }
527549
550+ /// Test rejecting an unknown Accept: / type
551+ #[ test]
552+ fn test_invalid_accept_header_type_slash ( ) {
553+ let mut headers = HeaderMap :: new ( ) ;
554+ headers. append ( ACCEPT , HeaderValue :: from_str ( "/" ) . unwrap ( ) ) ;
555+ let result = get_accept_types ( & headers) ;
556+ assert ! ( result. is_err( ) ) ;
557+ }
558+
528559 /// Test accepting one header with multiple values
529560 #[ test]
530561 fn test_accept_header_invalid_parse ( ) {
@@ -689,6 +720,24 @@ mod test {
689720 ) ;
690721 }
691722
723+ /// Multiple Accept header fields must be combined before parsing so
724+ /// q-values are ordered globally across all media ranges, not per
725+ /// header field.
726+ #[ test]
727+ fn test_multiple_accept_headers_q_value_ordering ( ) {
728+ let mut headers = HeaderMap :: new ( ) ;
729+
730+ // SSZ appears in the first header field but has a lower q-value.
731+ // JSON appears in the second header field and should win globally.
732+ headers. append ( ACCEPT , HeaderValue :: from_str ( "application/octet-stream;q=0.1" ) . unwrap ( ) ) ;
733+ headers. append ( ACCEPT , HeaderValue :: from_str ( "application/json;q=1.0" ) . unwrap ( ) ) ;
734+
735+ assert_eq ! ( get_accept_types( & headers) . unwrap( ) , AcceptedEncodings {
736+ primary: EncodingType :: Json ,
737+ fallback: Some ( EncodingType :: Ssz ) ,
738+ } ) ;
739+ }
740+
692741 /// Once primary and fallback are filled, further supported entries must
693742 /// not overwrite fallback. (Belt-and-suspenders — only two supported
694743 /// variants exist today, so this is mostly a guard against future
@@ -746,13 +795,6 @@ mod test {
746795 ) ;
747796 }
748797
749- /// Snapshot test: constant emits exactly what we document in
750- /// OUTBOUND_ACCEPT.
751- #[ test]
752- fn test_outbound_accept_constant_snapshot ( ) {
753- assert_eq ! ( OUTBOUND_ACCEPT , "application/octet-stream;q=1.0,application/json;q=0.9" ) ;
754- }
755-
756798 /// q-value ladder: first entry is 1.0, each subsequent entry drops by 0.1.
757799 #[ test]
758800 fn test_accept_q_value_for_index_ladder ( ) {
@@ -810,6 +852,13 @@ mod test {
810852 assert_eq ! ( get_content_type( & headers) , EncodingType :: Json ) ;
811853 }
812854
855+ #[ test]
856+ fn test_content_type_invalid_defaults_to_json ( ) {
857+ let mut headers = HeaderMap :: new ( ) ;
858+ headers. append ( CONTENT_TYPE , HeaderValue :: from_str ( "/" ) . unwrap ( ) ) ;
859+ assert_eq ! ( get_content_type( & headers) , EncodingType :: Json ) ;
860+ }
861+
813862 // ── get_consensus_version_header ─────────────────────────────────────────
814863
815864 #[ test]
0 commit comments