From 85c131ed4109854a845a05bd0441581c6081916f Mon Sep 17 00:00:00 2001 From: Nicholas Jackson Date: Fri, 6 Oct 2023 11:33:45 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20support=20for=20param?= =?UTF-8?q?eters=20in=20content=20negotiation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempts to approach the level of support offered by express, but behavior may differ in unusual corner cases. Some key behaviors from Express that are implemented: - If an offer does not have every parameter listed in the given Accept, it is rejected. - Parameters do not affect specificity. - In a given specificity, more parameters gives greater precedence - Parameters are unordered - Matching is case-insensitive - Surrounding quotes behave strangely (buggy?) in Express, so we only explicitly handle what RFC-9110 allows, which is a quoted value (right side of the equal sign) Behaviors were mostly reverse engineered from res.format in Express. https://www.rfc-editor.org/rfc/rfc9110#name-parameters --- ctx.go | 2 +- helpers.go | 373 ++++++++++++++++++++++++++++++++++--------- helpers_fuzz_test.go | 14 ++ helpers_test.go | 205 +++++++++++++++++++++++- 4 files changed, 512 insertions(+), 82 deletions(-) create mode 100644 helpers_fuzz_test.go diff --git a/ctx.go b/ctx.go index a5ba85f9478..c147a99803f 100644 --- a/ctx.go +++ b/ctx.go @@ -334,7 +334,7 @@ func (c *Ctx) Body() []byte { // Split and get the encodings list, in order to attend the // rule defined at: https://www.rfc-editor.org/rfc/rfc9110#section-8.4-5 - encodingOrder = getSplicedStrList(headerEncoding, encodingOrder) + encodingOrder = getSplicedStrList(headerEncoding, encodingOrder, ',') if len(encodingOrder) == 0 { return c.fasthttp.Request.Body() } diff --git a/helpers.go b/helpers.go index 00414589942..e6fdfb1b528 100644 --- a/helpers.go +++ b/helpers.go @@ -26,13 +26,14 @@ import ( ) // acceptType is a struct that holds the parsed value of an Accept header -// along with quality, specificity, and order. -// used for sorting accept headers. +// along with quality, specificity, parameters, and order. +// Used for sorting accept headers. type acceptedType struct { spec string quality float64 specificity int order int + params string } // getTLSConfig returns a net listener's tls config @@ -228,7 +229,7 @@ func getGroupPath(prefix, path string) string { // acceptsOffer This function determines if an offer matches a given specification. // It checks if the specification ends with a '*' or if the offer has the prefix of the specification. // Returns true if the offer matches the specification, false otherwise. -func acceptsOffer(spec, offer string) bool { +func acceptsOffer(spec, offer, _ string) bool { if len(spec) >= 1 && spec[len(spec)-1] == '*' { return true } else if strings.HasPrefix(spec, offer) { @@ -241,60 +242,120 @@ func acceptsOffer(spec, offer string) bool { // It checks if the specification is equal to */* (i.e., all types are accepted). // It gets the MIME type of the offer (either from the offer itself or by its file extension). // It checks if the offer MIME type matches the specification MIME type or if the specification is of the form /* and the offer MIME type has the same MIME type. +// It checks if the offer contains every parameter present in the specification. // Returns true if the offer type matches the specification, false otherwise. -func acceptsOfferType(spec, offerType string) bool { +func acceptsOfferType(spec, offerType, specParams string) bool { + offerMime := "" + var offerParams string + + if i := strings.IndexByte(offerType, ';'); i == -1 { + offerMime = offerType + } else { + offerMime = offerType[:i] + offerParams = offerType[i:] + } + // Accept: */* if spec == "*/*" { - return true + return paramsMatch(specParams, offerParams) } var mimetype string - if strings.IndexByte(offerType, '/') != -1 { - mimetype = offerType // MIME type + if strings.IndexByte(offerMime, '/') != -1 { + mimetype = offerMime // MIME type } else { - mimetype = utils.GetMIME(offerType) // extension + mimetype = utils.GetMIME(offerMime) // extension } if spec == mimetype { // Accept: / - return true + return paramsMatch(specParams, offerParams) } s := strings.IndexByte(mimetype, '/') // Accept: /* if strings.HasPrefix(spec, mimetype[:s]) && (spec[s:] == "/*" || mimetype[s:] == "/*") { - return true + return paramsMatch(specParams, offerParams) } return false } +// paramsMatch returns whether offerParams contains all parameters present in specParams. +// Matching is case insensitive, and surrounding quotes are stripped. +// See https://www.rfc-editor.org/rfc/rfc9110#name-parameters +func paramsMatch(specParamStr, offerParams string) bool { + if specParamStr == "" { + return true + } + + stillOk := true + specParams := make([][2]string, 0, 2) + forEachParameter(string(specParamStr), func(s1, s2 string) bool { + if s1 == "q" || s1 == "Q" { + return false + } + for i := range specParams { + if utils.EqualFold(s1, specParams[i][0]) { + specParams[i][1] = s2 + return false + } + } + specParams = append(specParams, [2]string{s1, s2}) + return true + }) + for i := range specParams { + foundKey := false + forEachParameter(offerParams, func(offerParam, offerVal string) bool { + if utils.EqualFold(specParams[i][0], offerParam) { + foundKey = true + stillOk = utils.EqualFold(specParams[i][1], offerVal) + return false + } + return true + }) + if !foundKey || !stillOk { + return false + } + } + return stillOk +} + // getSplicedStrList function takes a string and a string slice as an argument, divides the string into different -// elements divided by ',' and stores these elements in the string slice. -// It returns the populated string slice as an output. +// elements divided by sep and stores these elements in the string slice. +// +// It returns the populated string slice as an output with len equal to the number of spliced elements. // -// If the given slice hasn't enough space, it will allocate more and return. -func getSplicedStrList(headerValue string, dst []string) []string { +// If the given slice hasn't enough space, it will allocate more. +func getSplicedStrList(headerValue string, dst []string, sep rune) []string { if headerValue == "" { return nil } var ( index int - character rune - lastElementEndsAt uint8 insertIndex int + lastElementEndsAt uint8 + quotes uint8 ) - for index, character = range headerValue + "$" { - if character == ',' || index == len(headerValue) { + for index = 0; index < len(headerValue)+1; index++ { + if index == len(headerValue) || rune(headerValue[index]) == sep { + // grow dst if needed if insertIndex >= len(dst) { oldSlice := dst dst = make([]string, len(dst)+(len(dst)>>1)+2) copy(dst, oldSlice) } - dst[insertIndex] = utils.TrimLeft(headerValue[lastElementEndsAt:index], ' ') - lastElementEndsAt = uint8(index + 1) - insertIndex++ + // Don't split quoted strings. + // Adapted from: + // https://github.com/jshttp/negotiator/blob/40a5acb0c878cca951bc44d1d9e2ab1f90ae813e/lib/mediaType.js#L253 + if quotes%2 == 0 || index == len(headerValue) { + dst[insertIndex] = utils.TrimLeft(headerValue[lastElementEndsAt:index], ' ') + lastElementEndsAt = uint8(index + 1) + insertIndex++ + } + } else if headerValue[index] == '"' { + quotes++ } } @@ -304,8 +365,170 @@ func getSplicedStrList(headerValue string, dst []string) []string { return dst } +// forEachMediaRange parses an Accept or Content-Type header, calling functor +// on each media range. +// See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields +func forEachMediaRange(header string, functor func(string)) { + for len(header) > 0 { + n := 0 + header = utils.TrimLeft(header, ' ') + quotes := 0 + escaping := false + + if strings.IndexByte(header, '"') != -1 { + // Complex case. We need to keep track of quotes and quoted-pairs (i.e., characters escaped with \ ) + loop: + for n < len(header) { + switch header[n] { + case ',': + if quotes%2 == 0 { + break loop + } + case '"': + if !escaping { + quotes++ + } + case '\\': + if quotes%2 == 1 { + escaping = !escaping + } + } + n++ + } + } else { + // Simple case. Just look for the next comma. + if n = strings.IndexByte(header, ','); n == -1 { + n = len(header) + } + } + + functor(header[:n]) + + if n >= len(header) { + return + } + header = header[n+1:] + } +} + +// forEachParamter parses a given parameter list, calling functor +// on each valid parameter. If functor returns false, we stop processing. +// It expects a leading ';'. +// See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.6 +// According to RFC-9110 2.4, it is up to our discretion whether +// to attempt to recover from errors in HTTP semantics. Therefor, +// we take the simple approach and exit early when a semantic error +// is detected in the header. +// +// parameter = parameter-name "=" parameter-value +// parameter-name = token +// parameter-value = ( token / quoted-string ) +// parameters = *( OWS ";" OWS [ parameter ] ) +func forEachParameter(params string, functor func(string, string) bool) { + for len(params) > 0 { + // eat OWS ";" OWS + params = utils.TrimLeft(params, ' ') + if len(params) == 0 || params[0] != ';' { + return + } + params = utils.TrimLeft(params[1:], ' ') + + n := 0 + + // make sure the parameter is at least one character long + if len(params) == 0 || !validHeaderFieldByte(params[n]) { + return + } + n++ + for n < len(params) && validHeaderFieldByte(params[n]) { + n++ + } + + // We should hit a '=' (that has more characters after it) + // If not, the parameter is invalid. + // param=foo + // ~~~~~^ + if n >= len(params)-1 || params[n] != '=' { + return + } + param := params[:n] + n++ + + if params[n] == '"' { + // Handle quoted strings and quoted-pairs (i.e., characters escaped with \ ) + // See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4 + foundEndQuote := false + escaping := false + n++ + m := n + for ; n < len(params); n++ { + if params[n] == '"' && !escaping { + foundEndQuote = true + break + } + // Recipients that process the value of a quoted-string MUST handle + // a quoted-pair as if it were replaced by the octet following the backslash + escaping = params[n] == '\\' && !escaping + } + if !foundEndQuote { + // Not a valid parameter + return + } + if !functor(param, params[m:n]) { + return + } + n++ + } else if validHeaderFieldByte(params[n]) { + // Parse a normal value, which should just be a token. + m := n + n++ + for n < len(params) && validHeaderFieldByte(params[n]) { + n++ + } + if !functor(param, params[m:n]) { + return + } + } else { + // Value was invalid + return + } + params = params[n:] + } +} + +// validHeaderFieldByte returns true if a valid tchar +// See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2 +// Function copied from net/textproto/reader.go +func validHeaderFieldByte(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c >= 128, then 1<>64)) != 0 +} + // getOffer return valid offer for header negotiation -func getOffer(header string, isAccepted func(spec, offer string) bool, offers ...string) string { +func getOffer(header string, isAccepted func(spec, offer, specParams string) bool, offers ...string) string { if len(offers) == 0 { return "" } @@ -313,47 +536,52 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. return offers[0] } + acceptedTypes := make([]acceptedType, 0, 8) + order := 0 + // Parse header and get accepted types with their quality and specificity // See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields - spec, commaPos, order := "", 0, 0 - acceptedTypes := make([]acceptedType, 0, 20) - for len(header) > 0 { + forEachMediaRange(header, func(accept string) { order++ - - // Skip spaces - header = utils.TrimLeft(header, ' ') - - // Get spec - commaPos = strings.IndexByte(header, ',') - if commaPos != -1 { - spec = utils.Trim(header[:commaPos], ' ') - } else { - spec = utils.TrimLeft(header, ' ') - } - - // Get quality - quality := 1.0 - if factorSign := strings.IndexByte(spec, ';'); factorSign != -1 { - factor := utils.Trim(spec[factorSign+1:], ' ') - if strings.HasPrefix(factor, "q=") { - if q, err := fasthttp.ParseUfloat(utils.UnsafeBytes(factor[2:])); err == nil { - quality = q + spec, quality, params := accept, 1.0, "" + + if i := strings.IndexByte(accept, ';'); i != -1 { + spec = accept[:i] + + // The vast majority of requests will have only the q parameter with + // no whitespace. Check this first to see if we can skip + // the more involved parsing. + if strings.HasPrefix(accept[i:], ";q=") { + if j := strings.IndexByte(accept[i+3:], ';'); j == -1 { + if q, err := fasthttp.ParseUfloat([]byte(accept[i+3:])); err == nil { + quality = q + } } - } - spec = spec[:factorSign] - } - - // Skip if quality is 0.0 - // See: https://www.rfc-editor.org/rfc/rfc9110#quality.values - if quality == 0.0 { - if commaPos != -1 { - header = header[commaPos+1:] } else { - break + hasParams := false + forEachParameter(accept[i:], func(param, val string) bool { + if param == "q" || param == "Q" { + if q, err := fasthttp.ParseUfloat([]byte(val)); err == nil { + quality = q + } + return false + } + hasParams = true + return true + }) + if hasParams { + params = accept[i:] + } + } + // Skip this accept type if quality is 0.0 + // See: https://www.rfc-editor.org/rfc/rfc9110#quality.values + if quality == 0.0 { + return } - continue } + spec = utils.TrimRight(spec, ' ') + // Get specificity specificity := 0 // check for wildcard this could be a mime */* or a wildcard character * @@ -368,15 +596,8 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. } // Add to accepted types - acceptedTypes = append(acceptedTypes, acceptedType{spec, quality, specificity, order}) - - // Next - if commaPos != -1 { - header = header[commaPos+1:] - } else { - break - } - } + acceptedTypes = append(acceptedTypes, acceptedType{spec, quality, specificity, order, params}) + }) if len(acceptedTypes) > 1 { // Sort accepted types by quality and specificity, preserving order of equal elements @@ -389,7 +610,7 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. if len(offer) == 0 { continue } - if isAccepted(acceptedType.spec, offer) { + if isAccepted(acceptedType.spec, offer, acceptedType.params) { return offer } } @@ -399,30 +620,30 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. } // sortAcceptedTypes sorts accepted types by quality and specificity, preserving order of equal elements -// -// Parameters are not supported, they are ignored when sorting by specificity. -// +// A type with parameters has higher priority than an equivalent one without parameters. +// e.g., text/html;a=1;b=2 comes before text/html;a=1 // See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields -func sortAcceptedTypes(at *[]acceptedType) { - if at == nil || len(*at) < 2 { +func sortAcceptedTypes(acceptedTypes *[]acceptedType) { + if acceptedTypes == nil || len(*acceptedTypes) < 2 { return } - acceptedTypes := *at + at := *acceptedTypes - for i := 1; i < len(acceptedTypes); i++ { + for i := 1; i < len(at); i++ { lo, hi := 0, i-1 for lo <= hi { mid := (lo + hi) / 2 - if acceptedTypes[i].quality < acceptedTypes[mid].quality || - (acceptedTypes[i].quality == acceptedTypes[mid].quality && acceptedTypes[i].specificity < acceptedTypes[mid].specificity) || - (acceptedTypes[i].quality == acceptedTypes[mid].quality && acceptedTypes[i].specificity == acceptedTypes[mid].specificity && acceptedTypes[i].order > acceptedTypes[mid].order) { + if at[i].quality < at[mid].quality || + (at[i].quality == at[mid].quality && at[i].specificity < at[mid].specificity) || + (at[i].quality == at[mid].quality && at[i].specificity < at[mid].specificity && len(at[i].params) < len(at[mid].params)) || + (at[i].quality == at[mid].quality && at[i].specificity == at[mid].specificity && len(at[i].params) == len(at[mid].params) && at[i].order > at[mid].order) { lo = mid + 1 } else { hi = mid - 1 } } for j := i; j > lo; j-- { - acceptedTypes[j-1], acceptedTypes[j] = acceptedTypes[j], acceptedTypes[j-1] + at[j-1], at[j] = at[j], at[j-1] } } } diff --git a/helpers_fuzz_test.go b/helpers_fuzz_test.go new file mode 100644 index 00000000000..8a760b9441f --- /dev/null +++ b/helpers_fuzz_test.go @@ -0,0 +1,14 @@ +//go:build go1.18 + +package fiber + +import "testing" + +// go test -v -run=^$ -fuzz=Fuzz_Utils_GetOffer +func Fuzz_Utils_GetOffer(f *testing.F) { + bigHeader := `application/json; v=1; foo=bar; q=0.938; extra=param, text/plain;param="big fox"; q=0.43` + f.Add(bigHeader) + f.Fuzz(func(_ *testing.T, spec string) { + getOffer(spec, acceptsOfferType, `application/json;version=1;v=1;foo=bar`, `text/plain;param="big fox"`) + }) +} diff --git a/helpers_test.go b/helpers_test.go index 788b7a9a471..42911514c02 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -78,6 +78,25 @@ func Test_Utils_GetOffer(t *testing.T) { utils.AssertEqual(t, "application/pdf", getOffer("text/plain;q=0,application/pdf;q=0.9,*/*;q=0.000", acceptsOfferType, "application/pdf", "application/json")) utils.AssertEqual(t, "application/pdf", getOffer("text/plain;q=0,application/pdf;q=0.9,*/*;q=0.000", acceptsOfferType, "application/pdf", "application/json")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain;a=1", acceptsOfferType, "text/plain;a=1")) + // Reject if offer does not have all params + utils.AssertEqual(t, "", getOffer("text/plain;a=1;b=2", acceptsOfferType, "text/plain;b=2")) + // Spaces, quotes, out of order params, and case insensitivity + utils.AssertEqual(t, "text/plain", getOffer("text/plain ", acceptsOfferType, "text/plain")) + utils.AssertEqual(t, "text/plain;b=2;a=1", getOffer("text/plain ;a=1;b=2", acceptsOfferType, "text/plain;b=2;a=1")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain; a=1 ", acceptsOfferType, "text/plain;a=1")) + // utils.AssertEqual(t, `text/plain;a="1;b=2\",text/plain"`, getOffer(`text/plain;a="1;b=2\",text/plain";0.9`, acceptsOfferType, `text/plain;a=1;b=2`, `text/plain;a="1;b=2,text/plain"`)) + utils.AssertEqual(t, "text/plain;A=CAPS", getOffer(`text/plain;a="caPs"`, acceptsOfferType, "text/plain;A=CAPS")) + + // Priority + utils.AssertEqual(t, "text/plain", getOffer("text/plain", acceptsOfferType, "text/plain", "text/plain;a=1")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain", acceptsOfferType, "text/plain;a=1", "text/plain")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain,text/plain;a=1", acceptsOfferType, "text/plain", "text/plain;a=1")) + utils.AssertEqual(t, "text/plain", getOffer("text/plain;q=0.899,text/plain;a=1;q=0.898", acceptsOfferType, "text/plain", "text/plain;a=1")) + utils.AssertEqual(t, "text/plain;a=1;b=2", getOffer("text/plain,text/plain;a=1,text/plain;a=1;b=2", acceptsOfferType, "text/plain", "text/plain;a=1", "text/plain;a=1;b=2")) + // Takes the last value specified + utils.AssertEqual(t, "text/plain;a=1;b=2", getOffer("text/plain;a=1;b=1;B=2", acceptsOfferType, "text/plain;a=1;b=1", "text/plain;a=1;b=2")) + utils.AssertEqual(t, "", getOffer("utf-8, iso-8859-1;q=0.5", acceptsOffer)) utils.AssertEqual(t, "", getOffer("utf-8, iso-8859-1;q=0.5", acceptsOffer, "ascii")) utils.AssertEqual(t, "utf-8", getOffer("utf-8, iso-8859-1;q=0.5", acceptsOffer, "utf-8")) @@ -87,6 +106,7 @@ func Test_Utils_GetOffer(t *testing.T) { utils.AssertEqual(t, "", getOffer("gzip, deflate;q=0", acceptsOffer, "deflate")) } +// go test -v -run=^$ -bench=Benchmark_Utils_GetOffer -benchmem -count=4 func Benchmark_Utils_GetOffer(b *testing.B) { headers := []string{ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -107,6 +127,174 @@ func Benchmark_Utils_GetOffer(b *testing.B) { } } +// go test -v -run=^$ -bench=Benchmark_Utils_GetOffer_WithParams -benchmem -count=4 +func Benchmark_Utils_GetOffer_WithParams(b *testing.B) { + headers := []string{ + "text/html;p=1,application/xhtml+xml;p=1;b=2,application/xml;a=2;q=0.9,*/*;q=0.8", + "application/json; version=1", + "utf-8, iso-8859-1;q=0.5", + } + offers := [][]string{ + {"text/html;p=1", "application/xml;a=2", "application/xml+xhtml; p=1; b=2"}, + {"application/json; version=2"}, + {`utf-8;charset="utf-16"`}, + } + for n := 0; n < b.N; n++ { + for i, header := range headers { + getOffer(header, acceptsOfferType, offers[i]...) + } + } +} + +func Test_Utils_ForEachParameter(t *testing.T) { + expectedParams := [][]string{ + {"foo", "1"}, + {"bar", `20tw\",b\\b;sack o`}, + } + n := 0 + forEachParameter(` ; foo=1 ; bar="20tw\",b\\b;sack o" ; action=skip `, func(p, v string) bool { + utils.AssertEqual(t, expectedParams[n][0], p) + utils.AssertEqual(t, expectedParams[n][1], v) + n++ + return p != "bar" + }) + // Check that we exited on the second parameter (bar) + utils.AssertEqual(t, 2, n) +} + +func Benchmark_Utils_ForEachParameter(b *testing.B) { + for n := 0; n < b.N; n++ { + salem := make([][2]string, 0, 5) + forEachParameter(` ; josua=1 ; vermant="20tw\",bob;sack o" ; version=1; foo=bar; `, func(s1, s2 string) bool { + salem = append(salem, [2]string{s1, s2}) + return true + }) + } +} + +func Test_Utils_ParamsMatch(t *testing.T) { + testCases := []struct { + description string + accept string + offer string + match bool + }{ + { + description: "empty accept and offer", + accept: "", + offer: "", + match: true, + }, + { + description: "accept is empty, offer has params", + accept: "", + offer: ";foo=bar", + match: true, + }, + { + description: "offer is empty, accept has params", + accept: ";foo=bar", + offer: "", + match: false, + }, + { + description: "accept has extra parameters", + accept: ";foo=bar;a=1", + offer: ";foo=bar", + match: false, + }, + { + description: "matches regardless of order", + accept: "; a=1; b=2", + offer: ";b=2;a=1", + match: true, + }, + { + description: "case insensitive", + accept: ";ParaM=FoO", + offer: ";pAram=foO", + match: true, + }, + { + description: "ignores q", + accept: ";q=0.42", + offer: "", + match: true, + }, + } + + for _, tc := range testCases { + utils.AssertEqual(t, tc.match, paramsMatch(tc.accept, tc.offer), tc.description) + } +} + +func Benchmark_Utils_ParamsMatch(b *testing.B) { + for n := 0; n < b.N; n++ { + paramsMatch(`; appLe=orange; param="foo"`, `;param=foo; apple=orange`) + } +} + +func Test_Utils_AcceptsOfferType(t *testing.T) { + testCases := []struct { + description string + spec string + specParams string + offerType string + accepts bool + }{ + { + description: "no params, matching", + spec: "application/json", + offerType: "application/json", + accepts: true, + }, + { + description: "no params, mismatch", + spec: "application/json", + offerType: "application/xml", + accepts: false, + }, + { + description: "params match", + spec: "application/json", + specParams: `; format=foo; version=1`, + offerType: "application/json;version=1;format=foo;q=0.1", + accepts: true, + }, + { + description: "spec has extra params", + spec: "text/html", + specParams: "; charset=utf-8", + offerType: "text/html", + accepts: false, + }, + { + description: "offer has extra params", + spec: "text/html", + offerType: "text/html;charset=utf-8", + accepts: true, + }, + { + description: "ignores optional whitespace", + spec: "application/json", + specParams: `;format=foo; version=1`, + offerType: "application/json; version=1 ; format=foo ", + accepts: true, + }, + { + description: "ignores optional whitespace", + spec: "application/json", + specParams: `;format="foo bar"; version=1`, + offerType: `application/json;version="1";format="foo bar"`, + accepts: true, + }, + } + for _, tc := range testCases { + accepts := acceptsOfferType(tc.spec, tc.offerType, tc.specParams) + utils.AssertEqual(t, tc.accepts, accepts, tc.description) + } +} + func Test_Utils_GetSplicedStrList(t *testing.T) { testCases := []struct { description string @@ -133,25 +321,30 @@ func Test_Utils_GetSplicedStrList(t *testing.T) { headerValue: "gzip,", expectedList: []string{"gzip", ""}, }, + { + description: "does not split quoted elements", + headerValue: `gzip, "br,zip"`, + expectedList: []string{"gzip", `"br,zip"`}, + }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { dst := make([]string, 10) - result := getSplicedStrList(tc.headerValue, dst) + result := getSplicedStrList(tc.headerValue, dst, ',') utils.AssertEqual(t, tc.expectedList, result) }) } } func Benchmark_Utils_GetSplicedStrList(b *testing.B) { - destination := make([]string, 5) + destination := make([]string, 6) result := destination - const input = "deflate, gzip,br,brotli" + const input = `deflate, gzip,"weird,but valid",br,brotli` for n := 0; n < b.N; n++ { - result = getSplicedStrList(input, destination) + result = getSplicedStrList(input, destination, ',') } - utils.AssertEqual(b, []string{"deflate", "gzip", "br", "brotli"}, result) + utils.AssertEqual(b, []string{"deflate", "gzip", `"weird,but valid"`, "br", "brotli"}, result) } func Test_Utils_SortAcceptedTypes(t *testing.T) { @@ -168,6 +361,7 @@ func Test_Utils_SortAcceptedTypes(t *testing.T) { {spec: "image/*", quality: 1, specificity: 2, order: 8}, {spec: "image/gif", quality: 1, specificity: 3, order: 9}, {spec: "text/plain", quality: 1, specificity: 3, order: 10}, + {spec: "application/json", quality: 0.999, specificity: 3, params: ";a=1", order: 11}, } sortAcceptedTypes(&acceptedTypes) utils.AssertEqual(t, acceptedTypes, []acceptedType{ @@ -179,6 +373,7 @@ func Test_Utils_SortAcceptedTypes(t *testing.T) { {spec: "image/gif", quality: 1, specificity: 3, order: 9}, {spec: "text/plain", quality: 1, specificity: 3, order: 10}, {spec: "image/*", quality: 1, specificity: 2, order: 8}, + {spec: "application/json", quality: 0.999, specificity: 3, params: ";a=1", order: 11}, {spec: "application/json", quality: 0.999, specificity: 3, order: 3}, {spec: "text/*", quality: 0.5, specificity: 2, order: 1}, {spec: "*/*", quality: 0.1, specificity: 1, order: 2},