11#[ cfg( feature = "testing-flags" ) ]
22use std:: cell:: Cell ;
33use std:: {
4- collections:: { HashMap , HashSet } ,
4+ collections:: HashMap ,
55 fmt:: Display ,
66 net:: Ipv4Addr ,
77 str:: FromStr ,
@@ -27,7 +27,7 @@ use lh_types::{
2727 BeaconBlock ,
2828 test_utils:: { SeedableRng , TestRandom , XorShiftRng } ,
2929} ;
30- use mediatype:: MediaType ;
30+ use mediatype:: { MediaType , ReadParams } ;
3131use rand:: { Rng , distr:: Alphanumeric } ;
3232use reqwest:: {
3333 Response ,
@@ -565,38 +565,108 @@ pub fn get_user_agent_with_version(req_headers: &HeaderMap) -> eyre::Result<Head
565565 Ok ( HeaderValue :: from_str ( & format ! ( "commit-boost/{HEADER_VERSION_VALUE} {ua}" ) ) ?)
566566}
567567
568- /// Parse the ACCEPT header to get the type of response to encode the body with,
569- /// defaulting to JSON if missing. Returns an error if malformed or unsupported
570- /// types are requested. Supports requests with multiple ACCEPT headers or
571- /// headers with multiple media types.
572- pub fn get_accept_types ( req_headers : & HeaderMap ) -> eyre:: Result < HashSet < EncodingType > > {
573- let mut accepted_types = HashSet :: new ( ) ;
574- let mut unsupported_type = false ;
568+ /// Deterministic outbound `Accept` header used when PBS asks a relay for a
569+ /// response it will itself decode (validation mode On/Extra). SSZ is preferred
570+ /// for wire efficiency. Emitted verbatim so packet captures and support
571+ /// tickets are reproducible.
572+ pub const OUTBOUND_ACCEPT : & str = "application/octet-stream;q=1.0,application/json;q=0.9" ;
573+
574+ /// Parse the ACCEPT header into a q-value ordered list of supported
575+ /// [`EncodingType`]s (highest preference first, deduplicated), defaulting to
576+ /// the request's Content-Type when no Accept header is present. Returns an
577+ /// error only if every media type in the header is malformed or unsupported.
578+ /// Supports requests with multiple ACCEPT headers or headers with multiple
579+ /// media types. `q=0` entries are treated as explicit rejections per
580+ /// RFC 7231 §5.3.1 and are skipped.
581+ ///
582+ /// The returned order honors the RFC 9110 §12.5.1 precedence rules already
583+ /// applied by `headers_accept::Accept::media_types()` (specificity, then
584+ /// q-value, then original order).
585+ pub fn get_accept_types ( req_headers : & HeaderMap ) -> eyre:: Result < Vec < EncodingType > > {
586+ let mut ordered: Vec < EncodingType > = Vec :: new ( ) ;
587+ let mut saw_any = false ;
588+ let mut had_supported = false ;
575589 for header in req_headers. get_all ( ACCEPT ) . iter ( ) {
576590 let accept = Accept :: from_str ( header. to_str ( ) ?)
577591 . map_err ( |e| eyre:: eyre!( "invalid accept header: {e}" ) ) ?;
578592 for mt in accept. media_types ( ) {
579- match mt. essence ( ) . to_string ( ) . as_str ( ) {
580- APPLICATION_OCTET_STREAM => {
581- accepted_types. insert ( EncodingType :: Ssz ) ;
582- }
583- APPLICATION_JSON | WILDCARD => {
584- accepted_types. insert ( EncodingType :: Json ) ;
585- }
586- _ => unsupported_type = true ,
593+ saw_any = true ;
594+
595+ // Skip q=0 entries — RFC 7231 §5.3.1: "A request without any Accept
596+ // header field implies that the user agent will accept any media
597+ // type in response. When a header field is present ... a value of
598+ // 0 means 'not acceptable'."
599+ if let Some ( q) = mt. get_param ( mediatype:: names:: Q ) &&
600+ q. as_str ( ) . parse :: < f32 > ( ) . is_ok_and ( |v| v <= 0.0 )
601+ {
602+ continue ;
603+ }
604+
605+ let parsed = match mt. essence ( ) . to_string ( ) . as_str ( ) {
606+ APPLICATION_OCTET_STREAM => Some ( EncodingType :: Ssz ) ,
607+ APPLICATION_JSON | WILDCARD => Some ( EncodingType :: Json ) ,
608+ _ => None ,
587609 } ;
610+ if let Some ( enc) = parsed {
611+ had_supported = true ;
612+ if !ordered. contains ( & enc) {
613+ ordered. push ( enc) ;
614+ }
615+ }
588616 }
589617 }
590618
591- if accepted_types . is_empty ( ) {
592- if unsupported_type {
619+ if ordered . is_empty ( ) {
620+ if saw_any && !had_supported {
593621 return Err ( eyre:: eyre!( "unsupported accept type" ) ) ;
594622 }
595623
596- // No accept header so just return the same type as the content type
597- accepted_types. insert ( get_content_type ( req_headers) ) ;
624+ // No accept header (or only q=0 rejections): fall back to the request
625+ // Content-Type, which mirrors the historical behavior.
626+ ordered. push ( get_content_type ( req_headers) ) ;
598627 }
599- Ok ( accepted_types)
628+ Ok ( ordered)
629+ }
630+
631+ /// Pick the caller's highest-preference encoding from a list of types the
632+ /// server can actually produce. `accepts` is expected to be pre-ordered by
633+ /// descending preference (as returned by [`get_accept_types`]). Returns
634+ /// `None` if no overlap exists.
635+ pub fn preferred_encoding (
636+ accepts : & [ EncodingType ] ,
637+ supported : & [ EncodingType ] ,
638+ ) -> Option < EncodingType > {
639+ accepts. iter ( ) . copied ( ) . find ( |a| supported. contains ( a) )
640+ }
641+
642+ /// Compute the q-value for the `index`-th preferred encoding when building an
643+ /// outbound `Accept` header. The first entry gets q=1.0, each subsequent entry
644+ /// decreases by 0.1, and the value is clamped to a minimum of 0.1 so we never
645+ /// emit q=0 (which per RFC 7231 §5.3.1 means "not acceptable").
646+ fn accept_q_value_for_index ( index : usize ) -> f32 {
647+ // `as i32` would silently wrap for large indices (e.g. usize::MAX → -1),
648+ // which would invert the clamp. Saturate the cast explicitly.
649+ let idx = i32:: try_from ( index) . unwrap_or ( i32:: MAX ) ;
650+ let step = 10_i32 . saturating_sub ( idx) . max ( 1 ) ;
651+ step as f32 / 10.0
652+ }
653+
654+ /// Format a single `Accept` header entry as `"<media-type>;q=<x.x>"`.
655+ fn format_accept_entry ( enc : EncodingType , q : f32 ) -> String {
656+ format ! ( "{};q={:.1}" , enc. content_type( ) , q)
657+ }
658+
659+ /// Build an `Accept` header string that mirrors the caller's preference order
660+ /// so the relay sees the same priority the beacon node asked us for. Each
661+ /// subsequent entry receives a q-value 0.1 lower than the previous one,
662+ /// starting at 1.0. Returns an empty string for an empty preference list.
663+ pub fn build_outbound_accept ( preferred : & [ EncodingType ] ) -> String {
664+ preferred
665+ . iter ( )
666+ . enumerate ( )
667+ . map ( |( i, enc) | format_accept_entry ( * enc, accept_q_value_for_index ( i) ) )
668+ . collect :: < Vec < _ > > ( )
669+ . join ( "," )
600670}
601671
602672/// Parse CONTENT TYPE header to get the encoding type of the body, defaulting
@@ -919,10 +989,11 @@ mod test {
919989 use reqwest:: header:: { ACCEPT , CONTENT_TYPE } ;
920990
921991 use super :: {
922- BodyDeserializeError , CONSENSUS_VERSION_HEADER , create_admin_jwt, create_jwt,
923- decode_admin_jwt, decode_jwt, deserialize_body, get_consensus_version_header,
924- get_content_type, parse_response_encoding_and_fork, random_jwt_secret, validate_admin_jwt,
925- validate_jwt,
992+ BodyDeserializeError , CONSENSUS_VERSION_HEADER , OUTBOUND_ACCEPT , accept_q_value_for_index,
993+ build_outbound_accept, create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt,
994+ deserialize_body, format_accept_entry, get_consensus_version_header, get_content_type,
995+ parse_response_encoding_and_fork, preferred_encoding, random_jwt_secret,
996+ validate_admin_jwt, validate_jwt,
926997 } ;
927998 use crate :: {
928999 constants:: SIGNER_JWT_EXPIRATION ,
@@ -967,8 +1038,7 @@ mod test {
9671038 fn test_missing_accept_header ( ) {
9681039 let headers = HeaderMap :: new ( ) ;
9691040 let result = get_accept_types ( & headers) . unwrap ( ) ;
970- assert_eq ! ( result. len( ) , 1 ) ;
971- assert ! ( result. contains( & EncodingType :: Json ) ) ;
1041+ assert_eq ! ( result, vec![ EncodingType :: Json ] ) ;
9721042 }
9731043
9741044 /// Test accepting JSON
@@ -977,8 +1047,7 @@ mod test {
9771047 let mut headers = HeaderMap :: new ( ) ;
9781048 headers. append ( ACCEPT , HeaderValue :: from_str ( APPLICATION_JSON ) . unwrap ( ) ) ;
9791049 let result = get_accept_types ( & headers) . unwrap ( ) ;
980- assert_eq ! ( result. len( ) , 1 ) ;
981- assert ! ( result. contains( & EncodingType :: Json ) ) ;
1050+ assert_eq ! ( result, vec![ EncodingType :: Json ] ) ;
9821051 }
9831052
9841053 /// Test accepting SSZ
@@ -987,8 +1056,7 @@ mod test {
9871056 let mut headers = HeaderMap :: new ( ) ;
9881057 headers. append ( ACCEPT , HeaderValue :: from_str ( APPLICATION_OCTET_STREAM ) . unwrap ( ) ) ;
9891058 let result = get_accept_types ( & headers) . unwrap ( ) ;
990- assert_eq ! ( result. len( ) , 1 ) ;
991- assert ! ( result. contains( & EncodingType :: Ssz ) ) ;
1059+ assert_eq ! ( result, vec![ EncodingType :: Ssz ] ) ;
9921060 }
9931061
9941062 /// Test accepting wildcards
@@ -997,20 +1065,18 @@ mod test {
9971065 let mut headers = HeaderMap :: new ( ) ;
9981066 headers. append ( ACCEPT , HeaderValue :: from_str ( WILDCARD ) . unwrap ( ) ) ;
9991067 let result = get_accept_types ( & headers) . unwrap ( ) ;
1000- assert_eq ! ( result. len( ) , 1 ) ;
1001- assert ! ( result. contains( & EncodingType :: Json ) ) ;
1068+ assert_eq ! ( result, vec![ EncodingType :: Json ] ) ;
10021069 }
10031070
1004- /// Test accepting one header with multiple values
1071+ /// Test accepting one header with multiple values (order preserved,
1072+ /// first listed wins at equal q)
10051073 #[ test]
10061074 fn test_accept_header_multiple_values ( ) {
10071075 let header_string = format ! ( "{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}" ) ;
10081076 let mut headers = HeaderMap :: new ( ) ;
10091077 headers. append ( ACCEPT , HeaderValue :: from_str ( & header_string) . unwrap ( ) ) ;
10101078 let result = get_accept_types ( & headers) . unwrap ( ) ;
1011- assert_eq ! ( result. len( ) , 2 ) ;
1012- assert ! ( result. contains( & EncodingType :: Json ) ) ;
1013- assert ! ( result. contains( & EncodingType :: Ssz ) ) ;
1079+ assert_eq ! ( result, vec![ EncodingType :: Json , EncodingType :: Ssz ] ) ;
10141080 }
10151081
10161082 /// Test accepting multiple headers
@@ -1020,9 +1086,9 @@ mod test {
10201086 headers. append ( ACCEPT , HeaderValue :: from_str ( APPLICATION_JSON ) . unwrap ( ) ) ;
10211087 headers. append ( ACCEPT , HeaderValue :: from_str ( APPLICATION_OCTET_STREAM ) . unwrap ( ) ) ;
10221088 let result = get_accept_types ( & headers) . unwrap ( ) ;
1023- assert_eq ! ( result. len( ) , 2 ) ;
10241089 assert ! ( result. contains( & EncodingType :: Json ) ) ;
10251090 assert ! ( result. contains( & EncodingType :: Ssz ) ) ;
1091+ assert_eq ! ( result. len( ) , 2 ) ;
10261092 }
10271093
10281094 /// Test accepting one header with multiple values, including a type that
@@ -1034,9 +1100,7 @@ mod test {
10341100 let mut headers = HeaderMap :: new ( ) ;
10351101 headers. append ( ACCEPT , HeaderValue :: from_str ( & header_string) . unwrap ( ) ) ;
10361102 let result = get_accept_types ( & headers) . unwrap ( ) ;
1037- assert_eq ! ( result. len( ) , 2 ) ;
1038- assert ! ( result. contains( & EncodingType :: Json ) ) ;
1039- assert ! ( result. contains( & EncodingType :: Ssz ) ) ;
1103+ assert_eq ! ( result, vec![ EncodingType :: Json , EncodingType :: Ssz ] ) ;
10401104 }
10411105
10421106 /// Test rejecting an unknown accept type
@@ -1058,6 +1122,123 @@ mod test {
10581122 assert ! ( result. is_err( ) ) ;
10591123 }
10601124
1125+ /// q-values are honored: JSON@1.0 should outrank SSZ@0.1 regardless of
1126+ /// byte order in the header.
1127+ #[ test]
1128+ fn test_accept_header_q_value_ordering ( ) {
1129+ let mut headers = HeaderMap :: new ( ) ;
1130+ headers. append (
1131+ ACCEPT ,
1132+ HeaderValue :: from_str ( "application/json;q=1.0, application/octet-stream;q=0.1" )
1133+ . unwrap ( ) ,
1134+ ) ;
1135+ assert_eq ! ( get_accept_types( & headers) . unwrap( ) , vec![
1136+ EncodingType :: Json ,
1137+ EncodingType :: Ssz
1138+ ] ) ;
1139+
1140+ let mut headers = HeaderMap :: new ( ) ;
1141+ headers. append (
1142+ ACCEPT ,
1143+ HeaderValue :: from_str ( "application/octet-stream;q=0.1, application/json;q=1.0" )
1144+ . unwrap ( ) ,
1145+ ) ;
1146+ assert_eq ! ( get_accept_types( & headers) . unwrap( ) , vec![
1147+ EncodingType :: Json ,
1148+ EncodingType :: Ssz
1149+ ] ) ;
1150+ }
1151+
1152+ /// q=0 is an explicit rejection per RFC 7231 §5.3.1 and must be dropped.
1153+ #[ test]
1154+ fn test_accept_header_q_zero_rejected ( ) {
1155+ let mut headers = HeaderMap :: new ( ) ;
1156+ headers. append (
1157+ ACCEPT ,
1158+ HeaderValue :: from_str ( "application/json, application/octet-stream;q=0" ) . unwrap ( ) ,
1159+ ) ;
1160+ assert_eq ! ( get_accept_types( & headers) . unwrap( ) , vec![ EncodingType :: Json ] ) ;
1161+ }
1162+
1163+ /// An Accept header containing only q=0 for every supported type is a
1164+ /// deliberate "I accept nothing" and must error (so the route can return
1165+ /// 406 Not Acceptable per RFC 7231 §5.3.1 and §6.5.6).
1166+ #[ test]
1167+ fn test_accept_header_only_q_zero_errors ( ) {
1168+ let mut headers = HeaderMap :: new ( ) ;
1169+ headers. append (
1170+ ACCEPT ,
1171+ HeaderValue :: from_str ( "application/json;q=0, application/octet-stream;q=0" ) . unwrap ( ) ,
1172+ ) ;
1173+ assert ! ( get_accept_types( & headers) . is_err( ) ) ;
1174+ }
1175+
1176+ /// `preferred_encoding` picks the caller's first choice that the server
1177+ /// can actually produce.
1178+ #[ test]
1179+ fn test_preferred_encoding_picks_highest_q_match ( ) {
1180+ let accepts = [ EncodingType :: Json , EncodingType :: Ssz ] ;
1181+ let supported = [ EncodingType :: Ssz , EncodingType :: Json ] ;
1182+ assert_eq ! ( preferred_encoding( & accepts, & supported) , Some ( EncodingType :: Json ) ) ;
1183+
1184+ let accepts = [ EncodingType :: Ssz ] ;
1185+ let supported = [ EncodingType :: Json ] ;
1186+ assert_eq ! ( preferred_encoding( & accepts, & supported) , None ) ;
1187+ }
1188+
1189+ /// Outbound Accept should be deterministic and q-ordered to match caller
1190+ /// preference.
1191+ #[ test]
1192+ fn test_build_outbound_accept_deterministic ( ) {
1193+ let s = build_outbound_accept ( & [ EncodingType :: Ssz , EncodingType :: Json ] ) ;
1194+ assert_eq ! ( s, "application/octet-stream;q=1.0,application/json;q=0.9" ) ;
1195+
1196+ let s = build_outbound_accept ( & [ EncodingType :: Json , EncodingType :: Ssz ] ) ;
1197+ assert_eq ! ( s, "application/json;q=1.0,application/octet-stream;q=0.9" ) ;
1198+
1199+ // Stable across repeats
1200+ for _ in 0 ..100 {
1201+ assert_eq ! (
1202+ build_outbound_accept( & [ EncodingType :: Ssz , EncodingType :: Json ] ) ,
1203+ "application/octet-stream;q=1.0,application/json;q=0.9"
1204+ ) ;
1205+ }
1206+ }
1207+ /// Snapshot test: constant emits exactly what we document in
1208+ /// OUTBOUND_ACCEPT.
1209+ #[ test]
1210+ fn test_outbound_accept_constant_snapshot ( ) {
1211+ assert_eq ! ( OUTBOUND_ACCEPT , "application/octet-stream;q=1.0,application/json;q=0.9" ) ;
1212+ }
1213+
1214+ /// q-value ladder: first entry is 1.0, each subsequent entry drops by 0.1.
1215+ #[ test]
1216+ fn test_accept_q_value_for_index_ladder ( ) {
1217+ assert ! ( ( accept_q_value_for_index( 0 ) - 1.0 ) . abs( ) < f32 :: EPSILON ) ;
1218+ assert ! ( ( accept_q_value_for_index( 1 ) - 0.9 ) . abs( ) < f32 :: EPSILON ) ;
1219+ assert ! ( ( accept_q_value_for_index( 5 ) - 0.5 ) . abs( ) < f32 :: EPSILON ) ;
1220+ assert ! ( ( accept_q_value_for_index( 9 ) - 0.1 ) . abs( ) < f32 :: EPSILON ) ;
1221+ }
1222+
1223+ /// Clamp at 0.1: we never emit q=0 (which per RFC 7231 §5.3.1 would mean
1224+ /// "not acceptable").
1225+ #[ test]
1226+ fn test_accept_q_value_for_index_clamps_to_minimum ( ) {
1227+ assert ! ( ( accept_q_value_for_index( 10 ) - 0.1 ) . abs( ) < f32 :: EPSILON ) ;
1228+ assert ! ( ( accept_q_value_for_index( 100 ) - 0.1 ) . abs( ) < f32 :: EPSILON ) ;
1229+ // Even an adversarial usize::MAX must not underflow or drop to zero.
1230+ assert ! ( ( accept_q_value_for_index( usize :: MAX ) - 0.1 ) . abs( ) < f32 :: EPSILON ) ;
1231+ }
1232+
1233+ /// Entry formatter emits the spec-shaped string.
1234+ #[ test]
1235+ fn test_format_accept_entry_shape ( ) {
1236+ assert_eq ! ( format_accept_entry( EncodingType :: Ssz , 1.0 ) , "application/octet-stream;q=1.0" ) ;
1237+ assert_eq ! ( format_accept_entry( EncodingType :: Json , 0.9 ) , "application/json;q=0.9" ) ;
1238+ // One decimal place, even when the value has more precision.
1239+ assert_eq ! ( format_accept_entry( EncodingType :: Json , 0.12345 ) , "application/json;q=0.1" ) ;
1240+ }
1241+
10611242 #[ test]
10621243 fn test_jwt_validation_with_payload ( ) {
10631244 // Pretend payload
0 commit comments