@@ -243,19 +243,47 @@ func getGroupPath(prefix, path string) string {
243243 return utils .TrimRight (prefix , '/' ) + path
244244}
245245
246+ // Match specificity levels returned by the acceptsX helpers. A higher value
247+ // means the range matched the offer more specifically. They only need to be
248+ // ordered consistently within a single helper (each getOffer call uses one
249+ // helper), so an explicit q=0 rejection can override a positive match of the
250+ // same or lower specificity per RFC 9110 §12.5.1.
251+ const (
252+ matchWildcard = 1 // "*" / trailing-"*" prefix / language "*"
253+ matchPrefix = 2 // language subtag prefix, e.g. "en" matching "en-US"
254+ matchExact = 3 // exact, case-insensitive match
255+
256+ // Media ranges rank by their coarse class first and by the number of
257+ // matched media-type parameters second, so "text/html;level=1" outranks
258+ // "text/html", which outranks "text/*", which outranks "*/*".
259+ matchMediaAny = 1 // "*/*"
260+ matchMediaTypeAny = 2 // "type/*"
261+ matchMediaTypeSubtype = 3 // "type/subtype"
262+ mediaSpecificityScale = 100
263+ )
264+
246265// acceptsOffer determines if an offer matches a given specification.
247266// It supports a trailing '*' wildcard and performs case-insensitive exact matching.
248- // Returns true if the offer matches the specification, false otherwise.
249- func acceptsOffer (spec , offer string , _ headerParams ) bool {
267+ // It returns the match specificity (0 = no match, higher = more specific): a
268+ // wildcard/prefix match is less specific than an exact match. The specificity is
269+ // used to let an explicit q=0 rejection override a less specific positive match
270+ // of the same coarse class (RFC 9110 §12.5.1).
271+ func acceptsOffer (spec , offer string , _ headerParams ) int {
250272 if len (spec ) >= 1 && spec [len (spec )- 1 ] == '*' {
251273 prefix := spec [:len (spec )- 1 ]
252274 if len (offer ) < len (prefix ) {
253- return false
275+ return 0
254276 }
255- return utils .EqualFold (prefix , offer [:len (prefix )])
277+ if utils .EqualFold (prefix , offer [:len (prefix )]) {
278+ return matchWildcard
279+ }
280+ return 0
256281 }
257282
258- return utils .EqualFold (spec , offer )
283+ if utils .EqualFold (spec , offer ) {
284+ return matchExact
285+ }
286+ return 0
259287}
260288
261289// acceptsLanguageOfferBasic determines if a language tag offer matches a range
@@ -264,19 +292,25 @@ func acceptsOffer(spec, offer string, _ headerParams) bool {
264292// followed by a hyphen. The comparison is case-insensitive. Only a single "*"
265293// as the entire range is allowed. Any "*" appearing after a hyphen renders the
266294// range invalid and will not match.
267- func acceptsLanguageOfferBasic (spec , offer string , _ headerParams ) bool {
295+ // It returns the match specificity (0 = no match): "*" is least specific, a
296+ // prefix match ("en" for "en-US") is more specific, and an exact match is most
297+ // specific — so an explicit "en-US;q=0" can override a positive "en".
298+ func acceptsLanguageOfferBasic (spec , offer string , _ headerParams ) int {
268299 if spec == "*" {
269- return true
300+ return matchWildcard
270301 }
271302 if strings .IndexByte (spec , '*' ) >= 0 {
272- return false
303+ return 0
273304 }
274305 if utils .EqualFold (spec , offer ) {
275- return true
306+ return matchExact
276307 }
277- return len (offer ) > len (spec ) &&
308+ if len (offer ) > len (spec ) &&
278309 utils .EqualFold (offer [:len (spec )], spec ) &&
279- offer [len (spec )] == '-'
310+ offer [len (spec )] == '-' {
311+ return matchPrefix
312+ }
313+ return 0
280314}
281315
282316// acceptsLanguageOfferExtended determines if a language tag offer matches a
@@ -285,12 +319,16 @@ func acceptsLanguageOfferBasic(spec, offer string, _ headerParams) bool {
285319// - '*' matches zero or more subtags (can "slide")
286320// - Unspecified subtags are treated like '*' (so trailing/extraneous tag subtags are fine)
287321// - Matching fails if sliding encounters a singleton (incl. 'x')
288- func acceptsLanguageOfferExtended (spec , offer string , _ headerParams ) bool {
322+ // It returns the match specificity (0 = no match): a bare "*" is least specific,
323+ // and otherwise the specificity grows with the number of concrete range subtags
324+ // that had to match, so a deeper range (e.g. "en-US") outranks a shorter one
325+ // ("en") and an explicit "en-US;q=0" can override a positive "en".
326+ func acceptsLanguageOfferExtended (spec , offer string , _ headerParams ) int {
289327 if spec == "*" {
290- return true
328+ return matchWildcard
291329 }
292330 if spec == "" || offer == "" {
293- return false
331+ return 0
294332 }
295333
296334 // Use stack-allocated arrays to avoid heap allocations for typical language tags
@@ -309,7 +347,7 @@ func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
309347
310348 // Step 2: first subtag must match (or be '*')
311349 if rs [0 ] != "*" && ! utils .EqualFold (rs [0 ], ts [0 ]) {
312- return false
350+ return 0
313351 }
314352
315353 i , j := 1 , 1 // i = range index, j = tag index
@@ -319,7 +357,7 @@ func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
319357 continue
320358 }
321359 if j >= len (ts ) { // 3.B: ran out of tag subtags
322- return false
360+ return 0
323361 }
324362 if utils .EqualFold (rs [i ], ts [j ]) { // 3.C: exact subtag match
325363 i ++
@@ -328,22 +366,33 @@ func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
328366 }
329367 // 3.D: singleton barrier (one letter or digit, incl. 'x')
330368 if len (ts [j ]) == 1 {
331- return false
369+ return 0
332370 }
333371 // 3.E: slide forward in the tag and try again
334372 j ++
335373 }
336- // 4: matched all range subtags
337- return true
374+
375+ // 4: matched all range subtags. Rank by the number of concrete (non-"*")
376+ // range subtags so a more specific range wins the specificity comparison.
377+ specificity := matchWildcard
378+ for _ , sub := range rs {
379+ if sub != "*" {
380+ specificity ++
381+ }
382+ }
383+ return specificity
338384}
339385
340386// acceptsOfferType This function determines if an offer type matches a given specification.
341387// It checks if the specification is equal to */* (i.e., all types are accepted).
342388// It gets the MIME type of the offer (either from the offer itself or by its file extension).
343389// It checks if the offer MIME type matches the specification MIME type or if the specification is of the form <MIME_type>/* and the offer MIME type has the same MIME type.
344390// It checks if the offer contains every parameter present in the specification.
345- // Returns true if the offer type matches the specification, false otherwise.
346- func acceptsOfferType (spec , offerType string , specParams headerParams ) bool {
391+ // It returns the match specificity (0 = no match): "*/*" is least specific,
392+ // then "type/*", then "type/subtype", and matched media-type parameters break
393+ // ties so "text/html;level=1" outranks "text/html" (letting "text/html;level=1;q=0"
394+ // override a positive "text/html").
395+ func acceptsOfferType (spec , offerType string , specParams headerParams ) int {
347396 var offerMime , offerParams string
348397
349398 if i := strings .IndexByte (offerType , ';' ); i == - 1 {
@@ -355,7 +404,7 @@ func acceptsOfferType(spec, offerType string, specParams headerParams) bool {
355404
356405 // Accept: */*
357406 if spec == "*/*" {
358- return paramsMatch ( specParams , offerParams )
407+ return mediaMatchSpecificity ( matchMediaAny , specParams , offerParams )
359408 }
360409
361410 var mimetype string
@@ -367,19 +416,29 @@ func acceptsOfferType(spec, offerType string, specParams headerParams) bool {
367416
368417 if utils .EqualFold (spec , mimetype ) {
369418 // Accept: <MIME_type>/<MIME_subtype>
370- return paramsMatch ( specParams , offerParams )
419+ return mediaMatchSpecificity ( matchMediaTypeSubtype , specParams , offerParams )
371420 }
372421
373422 s := strings .IndexByte (mimetype , '/' )
374423 specSlash := strings .IndexByte (spec , '/' )
375424 // Accept: <MIME_type>/*
376425 if s != - 1 && specSlash != - 1 {
377426 if utils .EqualFold (spec [:specSlash ], mimetype [:s ]) && (spec [specSlash :] == "/*" || mimetype [s :] == "/*" ) {
378- return paramsMatch ( specParams , offerParams )
427+ return mediaMatchSpecificity ( matchMediaTypeAny , specParams , offerParams )
379428 }
380429 }
381430
382- return false
431+ return 0
432+ }
433+
434+ // mediaMatchSpecificity returns the specificity of a media range that matched an
435+ // offer's type/subtype, or 0 when the media-type parameters don't match. The
436+ // coarse class dominates; the count of matched parameters breaks ties.
437+ func mediaMatchSpecificity (base int , specParams headerParams , offerParams string ) int {
438+ if ! paramsMatch (specParams , offerParams ) {
439+ return 0
440+ }
441+ return base * mediaSpecificityScale + len (specParams )
383442}
384443
385444// paramsMatch returns whether offerParams contains all parameters present in specParams.
@@ -601,7 +660,7 @@ var headerParamPool = sync.Pool{
601660}
602661
603662// getOffer return valid offer for header negotiation.
604- func getOffer (header []byte , isAccepted func (spec , offer string , specParams headerParams ) bool , offers ... string ) string {
663+ func getOffer (header []byte , isAccepted func (spec , offer string , specParams headerParams ) int , offers ... string ) string {
605664 if len (offers ) == 0 {
606665 return ""
607666 }
@@ -611,6 +670,9 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
611670
612671 acceptedTypes := make ([]acceptedType , 0 , 8 )
613672 order := 0
673+ // Whether any range carries an explicit q=0 rejection. When none do, the
674+ // more-specific-rejection scan can be skipped entirely on the hot path.
675+ hasRejections := false
614676
615677 // Parse header and get accepted types with their quality and specificity
616678 // See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields
@@ -651,12 +713,6 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
651713 return true
652714 })
653715 }
654-
655- // Skip this accept type if quality is 0.0
656- // See: https://www.rfc-editor.org/rfc/rfc9110#quality.values
657- if quality == 0.0 {
658- return
659- }
660716 }
661717
662718 spec = utils .TrimSpace (spec )
@@ -678,6 +734,10 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
678734 specificity = 4
679735 }
680736
737+ if quality == 0 {
738+ hasRejections = true
739+ }
740+
681741 // Add to accepted types
682742 acceptedTypes = append (acceptedTypes , acceptedType {
683743 spec : utils .UnsafeString (spec ),
@@ -693,25 +753,78 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
693753 sortAcceptedTypes (acceptedTypes )
694754 }
695755
696- // Find the first offer that matches the accepted types
697- for _ , acceptedType := range acceptedTypes {
698- for _ , offer := range offers {
699- if offer == "" {
756+ // Find the best offer that matches the accepted types.
757+ //
758+ // Per RFC 9110 §12.5.1 the most specific matching media range determines an
759+ // offer's acceptability, and a quality of 0 means the client explicitly
760+ // rejects that range. An offer is therefore only acceptable if its most
761+ // specific matching range has a quality greater than 0 — a broader range
762+ // with a higher quality (e.g. "*" or "text/*") must not override a more
763+ // specific q=0 rejection.
764+ // See: https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1
765+ result := ""
766+ if ! hasRejections {
767+ // Fast path: without any q=0 rejection this is the plain "first matching
768+ // range in preference order wins" selection, identical to the algorithm
769+ // before q=0 handling, so there is no need to compute or compare match
770+ // specificity.
771+ selectFast:
772+ for _ , acceptedType := range acceptedTypes {
773+ for _ , offer := range offers {
774+ if offer != "" && isAccepted (acceptedType .spec , offer , acceptedType .params ) > 0 {
775+ result = offer
776+ break selectFast
777+ }
778+ }
779+ }
780+ } else {
781+ // Rejection-aware path: an offer is only acceptable if its matching range
782+ // is not overridden by a q=0 range that matches it at least as
783+ // specifically (RFC 9110 §12.5.1).
784+ selectWithRejections:
785+ for _ , acceptedType := range acceptedTypes {
786+ if acceptedType .quality == 0 {
787+ // A q=0 range never selects an offer; it can only reject one.
700788 continue
701789 }
702- if isAccepted (acceptedType .spec , offer , acceptedType .params ) {
703- if acceptedType .params != nil {
704- headerParamPool .Put (acceptedType .params )
790+ for _ , offer := range offers {
791+ if offer == "" {
792+ continue
793+ }
794+ matchSpecificity := isAccepted (acceptedType .spec , offer , acceptedType .params )
795+ if matchSpecificity > 0 &&
796+ ! rejectedByMoreSpecificRange (acceptedTypes , isAccepted , offer , matchSpecificity ) {
797+ result = offer
798+ break selectWithRejections
705799 }
706- return offer
707800 }
708801 }
709- if acceptedType .params != nil {
710- headerParamPool .Put (acceptedType .params )
802+ }
803+
804+ for i := range acceptedTypes {
805+ if acceptedTypes [i ].params != nil {
806+ headerParamPool .Put (acceptedTypes [i ].params )
711807 }
712808 }
713809
714- return ""
810+ return result
811+ }
812+
813+ // rejectedByMoreSpecificRange reports whether a q=0 range matches the offer at
814+ // least as specifically as the positive match at baseSpecificity, i.e. the
815+ // client explicitly rejected the offer per RFC 9110 §12.5.1. Comparing the
816+ // effective match specificity (rather than the coarse parsed bucket) lets a
817+ // same-class rejection win — e.g. "en-US;q=0" over "en", "utf-8;q=0" over an
818+ // earlier "utf-8", or "text/html;level=1;q=0" over "text/html" — while a less
819+ // specific rejection such as "text/*;q=0" still does not override "text/html".
820+ func rejectedByMoreSpecificRange (types []acceptedType , isAccepted func (spec , offer string , specParams headerParams ) int , offer string , baseSpecificity int ) bool {
821+ for i := range types {
822+ if types [i ].quality == 0 &&
823+ isAccepted (types [i ].spec , offer , types [i ].params ) >= baseSpecificity {
824+ return true
825+ }
826+ }
827+ return false
715828}
716829
717830// sortAcceptedTypes sorts accepted types by quality and specificity, preserving order of equal elements
0 commit comments