-
-
Notifications
You must be signed in to change notification settings - Fork 186
Description
Summary
goccy/go-json fails to perform case-insensitive JSON key matching (as specified by encoding/json) when the target struct has 17 or more visible JSON fields (fields with a json tag that is not "-"). Structs with 16 or fewer fields work correctly.
Versions Affected
All tested versions are affected:
- v0.9.7
- v0.10.5
Expected Behavior
Per the encoding/json specification:
To unmarshal JSON into a struct, Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match.
goccy/go-json documents itself as a drop-in replacement for encoding/json. Case-insensitive matching should work regardless of the number of fields in the struct.
Reproduction
package main
import (
"encoding/json"
"fmt"
goccy "github.com/goccy/go-json"
)
// 16 fields — case-insensitive matching works
type small struct {
F1 string `json:"f1"`
F2 string `json:"f2"`
F3 string `json:"f3"`
F4 string `json:"f4"`
F5 string `json:"f5"`
F6 string `json:"f6"`
F7 string `json:"f7"`
F8 string `json:"f8"`
F9 string `json:"f9"`
F10 string `json:"f10"`
F11 string `json:"f11"`
F12 string `json:"f12"`
F13 string `json:"f13"`
F14 string `json:"f14"`
F15 string `json:"f15"`
Name bool `json:"name,omitempty"`
}
// 17 fields — case-insensitive matching FAILS
type large struct {
F1 string `json:"f1"`
F2 string `json:"f2"`
F3 string `json:"f3"`
F4 string `json:"f4"`
F5 string `json:"f5"`
F6 string `json:"f6"`
F7 string `json:"f7"`
F8 string `json:"f8"`
F9 string `json:"f9"`
F10 string `json:"f10"`
F11 string `json:"f11"`
F12 string `json:"f12"`
F13 string `json:"f13"`
F14 string `json:"f14"`
F15 string `json:"f15"`
F16 string `json:"f16"`
Name bool `json:"name,omitempty"`
}
func main() {
// JSON key "Name" should match struct tag "name" (case-insensitive)
input := []byte(`{"Name":true}`)
fmt.Printf("Input: %s\n\n", input)
// 16 fields - works
var g1 small
goccy.Unmarshal(input, &g1)
var s1 small
json.Unmarshal(input, &s1)
fmt.Printf("[16 fields] goccy: Name=%v | encoding/json: Name=%v\n", g1.Name, s1.Name)
// 17 fields - BUG: goccy fails to match
var g2 large
goccy.Unmarshal(input, &g2)
var s2 large
json.Unmarshal(input, &s2)
fmt.Printf("[17 fields] goccy: Name=%v | encoding/json: Name=%v\n", g2.Name, s2.Name)
}Output
Input: {"Name":true}
[16 fields] goccy: Name=true | encoding/json: Name=true
[17 fields] goccy: Name=false | encoding/json: Name=true
Test with ignored field - works when visible fields number is <= 16
Test case with 17 fields when one of them is ignored (`json:"-"`)
package main
import (
"encoding/json"
"fmt"
goccy "github.com/goccy/go-json"
)
// 16 fields — case-insensitive matching works
type small struct {
F1 string `json:"f1"`
F2 string `json:"f2"`
F3 string `json:"f3"`
F4 string `json:"f4"`
F5 string `json:"f5"`
F6 string `json:"f6"`
F7 string `json:"f7"`
F8 string `json:"f8"`
F9 string `json:"f9"`
F10 string `json:"f10"`
F11 string `json:"f11"`
F12 string `json:"f12"`
F13 string `json:"f13"`
F14 string `json:"f14"`
F15 string `json:"f15"`
Name bool `json:"name,omitempty"`
}
// 17 fields — case-insensitive matching FAILS
type large struct {
F1 string `json:"f1"`
F2 string `json:"f2"`
F3 string `json:"f3"`
F4 string `json:"f4"`
F5 string `json:"f5"`
F6 string `json:"f6"`
F7 string `json:"f7"`
F8 string `json:"f8"`
F9 string `json:"f9"`
F10 string `json:"f10"`
F11 string `json:"f11"`
F12 string `json:"f12"`
F13 string `json:"f13"`
F14 string `json:"f14"`
F15 string `json:"f15"`
F16 string `json:"-"`
Name bool `json:"name,omitempty"`
}
func main() {
// JSON key "Name" should match struct tag "name" (case-insensitive)
input := []byte(`{"Name":true}`)
fmt.Printf("Input: %s\n\n", input)
// 16 fields - works
var g1 small
goccy.Unmarshal(input, &g1)
var s1 small
json.Unmarshal(input, &s1)
fmt.Printf("[16 fields] goccy: Name=%v | encoding/json: Name=%v\n", g1.Name, s1.Name)
// 17 fields - works because of ignored field
var g2 large
goccy.Unmarshal(input, &g2)
var s2 large
json.Unmarshal(input, &s2)
fmt.Printf("[17 fields] goccy: Name=%v | encoding/json: Name=%v\n", g2.Name, s2.Name)
}Output
Input: {"Name":true}
[16 fields] goccy: Name=true | encoding/json: Name=true
[17 fields] goccy: Name=true | encoding/json: Name=true
Impact
This is a silent data loss bug - no error is returned, the field is simply left at its zero value. This makes it particularly dangerous because:
- It only manifests when a struct grows past 16 fields, which can happen during normal development
- It only affects requests that rely on case-insensitive matching, so it may not be caught by tests that use exact-case keys
- There is no error to catch -
Unmarshalreturnsnil
I've discovered this in production when customers' API requests started failing after we added new fields to a struct that previously had 16 visible JSON fields.