diff --git a/encoding/cbor.go b/encoding/cbor.go index afe76feb..30f07bb4 100644 --- a/encoding/cbor.go +++ b/encoding/cbor.go @@ -1,4 +1,4 @@ -// Copyright 2023-2024 Contributors to the Veraison project. +// Copyright 2023-2025 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package encoding @@ -9,6 +9,7 @@ import ( "fmt" "math" "reflect" + "sort" "strconv" "strings" @@ -49,6 +50,13 @@ func doSerializeStructToCBOR( continue } + _, ok := typeField.Tag.Lookup("field-cache") + if ok { + if err := addCachedFieldsToMapCBOR(em, valField, rawMap); err != nil { + return err + } + } + tag, ok := typeField.Tag.Lookup("cbor") if !ok { continue @@ -129,6 +137,7 @@ func doPopulateStructFromCBOR( } var embeds []embedded + var fieldCache reflect.Value for i := 0; i < structVal.NumField(); i++ { typeField := structType.Field(i) @@ -138,6 +147,12 @@ func doPopulateStructFromCBOR( continue } + _, ok := typeField.Tag.Lookup("field-cache") + if ok { + fieldCache = valField + continue + } + tag, ok := typeField.Tag.Lookup("cbor") if !ok { continue @@ -192,7 +207,9 @@ func doPopulateStructFromCBOR( } } - return nil + // Any remaining contents of rawMap will be added to the field cache, + // if current struct has one. + return updateFieldCacheCBOR(dm, fieldCache, rawMap) } // structFieldsCBOR is a specialized implementation of "OrderedMap", where the @@ -269,6 +286,7 @@ func (o *structFieldsCBOR) ToCBOR(em cbor.EncMode) ([]byte, error) { return nil, errors.New("mapLen cannot exceed math.MaxUint32") } + lexSort(em, o.Keys) for _, key := range o.Keys { marshalledKey, err := em.Marshal(key) if err != nil { @@ -414,3 +432,99 @@ func processAdditionalInfo( return mapLen, rest, nil } + +func addCachedFieldsToMapCBOR(em cbor.EncMode, cacheField reflect.Value, rawMap *structFieldsCBOR) error { + if !isMapStringAny(cacheField) { + return errors.New("field-cache does not appear to be a map[string]any") + } + + if !cacheField.IsValid() || cacheField.IsNil() { + // field cache was never set, so nothing to do + return nil + } + + for _, key := range cacheField.MapKeys() { + keyText := key.String() + keyInt, err := strconv.Atoi(keyText) + if err != nil { + return fmt.Errorf( + "cached field name not an integer (cannot encode to CBOR): %s", + keyText, + ) + } + + data, err := em.Marshal(cacheField.MapIndex(key).Interface()) + if err != nil { + return fmt.Errorf( + "error marshaling field-cache entry %q: %w", + keyText, + err, + ) + } + + if err := rawMap.Add(keyInt, cbor.RawMessage(data)); err != nil { + return fmt.Errorf( + "could not add field-cache entry %q to serialization map: %w", + keyText, + err, + ) + } + } + + return nil +} + +func updateFieldCacheCBOR(dm cbor.DecMode, cacheField reflect.Value, rawMap *structFieldsCBOR) error { + if !cacheField.IsValid() { + // current struct does not have a field-cache field + return nil + } + + if !isMapStringAny(cacheField) { + return errors.New("field-cache does not appear to be a map[string]any") + } + + if cacheField.IsNil() { + cacheField.Set(reflect.MakeMap(cacheField.Type())) + } + + for key, rawVal := range rawMap.Fields { + var val any + if err := dm.Unmarshal(rawVal, &val); err != nil { + return fmt.Errorf("could not unmarshal key %d: %w", key, err) + } + + keyText := fmt.Sprint(key) + keyVal := reflect.ValueOf(keyText) + valVal := reflect.ValueOf(val) + cacheField.SetMapIndex(keyVal, valVal) + } + + return nil +} + +// Lexicographic sorting of CBOR integer keys. See: +// https://www.ietf.org/archive/id/draft-ietf-cbor-cde-13.html#name-the-lexicographic-map-sorti +func lexSort(em cbor.EncMode, v []int) { + sort.Slice(v, func(i, j int) bool { + a, err := em.Marshal(v[i]) + if err != nil { + panic(err) // integer encoding cannot fail + } + + b, err := em.Marshal(v[j]) + if err != nil { + panic(err) // integer encoding cannot fail + } + + for k, v := range a { + if v < b[k] { + return true + } else if v > b[k] { + return false + } + } + + return false + }) +} diff --git a/encoding/cbor_test.go b/encoding/cbor_test.go index f63a9380..dc449332 100644 --- a/encoding/cbor_test.go +++ b/encoding/cbor_test.go @@ -1,4 +1,4 @@ -// Copyright 2021-2024 Contributors to the Veraison project. +// Copyright 2021-2025 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package encoding @@ -107,6 +107,41 @@ func Test_PopulateStructFromCBOR_simple(t *testing.T) { } +func Test_SerializeStructToCBOR_cde_ordering(t *testing.T) { + val := struct { + Field8 int `cbor:"8,keyasint"` + FieldN3 int `cbor:"-3,keyasint"` + Field0 int `cbor:"0,keyasint"` + FieldN1 int `cbor:"-1,keyasint"` + }{ + Field8: 1, + FieldN3: 3, + Field0: 0, + FieldN1: 2, + } + + expected := []byte{ + 0xa4, // map(4) + + 0x00, // key: 0 + 0x00, // value: 0 + + 0x08, // key: 8 + 0x01, // value: 1 + + 0x20, // key: -1 + 0x02, // value: 2 + + 0x22, // key: -3 + 0x03, // value: 3 + } + + em := mustInitEncMode() + data, err := SerializeStructToCBOR(em, val) + assert.NoError(t, err) + assert.Equal(t, expected, data) +} + func Test_structFieldsCBOR_CRUD(t *testing.T) { sf := newStructFieldsCBOR() @@ -278,3 +313,45 @@ func Test_processAdditionalInfo(t *testing.T) { _, _, err = processAdditionalInfo(addInfo, []byte{}) assert.EqualError(t, err, "unexpected EOF") } + +func Test_lexSort(t *testing.T) { + test_cases := []struct { + title string + input []int + expected []int + }{ + { + title: "non-negative", + input: []int{1, 4, 0, 2, 3}, + expected: []int{0, 1, 2, 3, 4}, + }, + { + title: "negative", + input: []int{-1, -4, -2, -3}, + expected: []int{-1, -2, -3, -4}, + }, + { + title: "mixed", + input: []int{-1, 0, 3, 1, -2}, + expected: []int{0, 1, 3, -1, -2}, + }, + { + title: "already sorted", + input: []int{0, 1, 3, -1, -2}, + expected: []int{0, 1, 3, -1, -2}, + }, + { + title: "different length encoding", + input: []int{65535, 256}, + expected: []int{256, 65535}, + }, + } + + for _, tc := range test_cases { + em := mustInitEncMode() + t.Run(tc.title, func(t *testing.T) { + lexSort(em, tc.input) + assert.Equal(t, tc.expected, tc.input) + }) + } +} diff --git a/encoding/embedded.go b/encoding/embedded.go index 8f758cf3..ccd3f4b6 100644 --- a/encoding/embedded.go +++ b/encoding/embedded.go @@ -1,4 +1,4 @@ -// Copyright 2024 Contributors to the Veraison project. +// Copyright 2024-2025 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package encoding @@ -49,3 +49,21 @@ func collectEmbedded( return false } + +// isMapStringAny returns true iff the provided value, v, is of type +// map[string]any. +func isMapStringAny(v reflect.Value) bool { + if v.Kind() != reflect.Map { + return false + } + + if v.Type().Key().Kind() != reflect.String { + return false + } + + if v.Type().Elem().Kind() != reflect.Interface { + return false + } + + return true +} diff --git a/encoding/field_cache_test.go b/encoding/field_cache_test.go new file mode 100644 index 00000000..21c6cd37 --- /dev/null +++ b/encoding/field_cache_test.go @@ -0,0 +1,179 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package encoding + +import ( + "testing" + + cbor "github.com/fxamacker/cbor/v2" + "github.com/stretchr/testify/assert" +) + +// The following structs emulate the embedding pattern used for extensions + +type IEmbeddedValue any + +type Embedded struct { + IEmbeddedValue `json:"embedded,omitempty"` + + FieldCache map[string]any `field-cache:"" cbor:"-" json:"-"` +} + +type MyStruct struct { + Field0 string `cbor:"0,keyasint,omitempty" json:"field0,omitempty"` + Field1 int `cbor:"1,keyasint,omitempty" json:"field1,omitempty"` + + Embedded +} + +type MyEmbed struct { + Foo string `cbor:"-1,keyasint,omitempty" json:"foo,omitempty"` +} + +type EmbeddedNoCache struct { + IEmbeddedValue `json:"embedded,omitempty"` +} + +type MyStructNoCache struct { + Field0 string `cbor:"0,keyasint,omitempty" json:"field0,omitempty"` + Field1 int `cbor:"1,keyasint,omitempty" json:"field1,omitempty"` + + EmbeddedNoCache +} + +func mustInitEncMode() cbor.EncMode { + encOpt := cbor.EncOptions{ + Sort: cbor.SortCoreDeterministic, + IndefLength: cbor.IndefLengthForbidden, + TimeTag: cbor.EncTagRequired, + } + + em, err := encOpt.EncMode() + if err != nil { + panic(err) + } + + return em +} + +func mustInitDecMode() cbor.DecMode { + decOpt := cbor.DecOptions{ + IndefLength: cbor.IndefLengthAllowed, + } + + dm, err := decOpt.DecMode() + if err != nil { + panic(err) + } + + return dm +} + +func Test_preserve_unknown_embeds_CBOR(t *testing.T) { + // nolint: gocritic + data := []byte{ + 0xa3, // map(3) + + 0x00, // key: 0 + 0x62, // value: tstr(2) + 0x66, 0x31, // "f1" + + 0x01, // key: 1 + 0x02, // value: 2 + + 0x20, // key: -1 + 0x63, // value: tstr(3) + 0x62, 0x61, 0x72, // "bar" + } + + em := mustInitEncMode() + dm := mustInitDecMode() + + // First, a sanity test to make sure that embedded data + // is preserved when there is a concrete struct to + // contain it. + embed := MyEmbed{} + myStruct := MyStruct{Embedded: Embedded{IEmbeddedValue: &embed, FieldCache: map[string]any{}}} + + err := PopulateStructFromCBOR(dm, data, &myStruct) + assert.NoError(t, err) + + outData, err := SerializeStructToCBOR(em, &myStruct) + assert.NoError(t, err) + assert.Equal(t, data, outData) + + // Now, the same test with IEmbeddedValue not set. This simulates the + // case where extensions are present in the data but the struct needed + // to understand them has not been registered. + myStruct = MyStruct{} + + err = PopulateStructFromCBOR(dm, data, &myStruct) + assert.NoError(t, err) + + outData, err = SerializeStructToCBOR(em, &myStruct) + assert.NoError(t, err) + assert.Equal(t, data, outData) + + // Make sure that, without caching, unknown values are simply ignored + // without causing errors. + noCache := MyStructNoCache{} + err = PopulateStructFromCBOR(dm, data, &noCache) + assert.NoError(t, err) + + // nolint: gocritic + expectedNoEmbed := []byte{ + 0xa2, // map(2) + + 0x00, // key: 0 + 0x62, // value: tstr(2) + 0x66, 0x31, // "f1" + + 0x01, // key: 1 + 0x02, // value: 2 + } + + outData, err = SerializeStructToCBOR(em, &noCache) + assert.NoError(t, err) + assert.Equal(t, expectedNoEmbed, outData) +} + +func Test_preserve_unknown_embeds_JSON(t *testing.T) { + data := []byte(`{"field0":"f1","field1":2,"foo":"bar"}`) + + // First, a sanity test to make sure that embedded data + // is presevered when there is a concrete struct to + // contain it. + embed := MyEmbed{} + myStruct := MyStruct{Embedded: Embedded{IEmbeddedValue: &embed, FieldCache: map[string]any{}}} + + err := PopulateStructFromJSON(data, &myStruct) + assert.NoError(t, err) + + outData, err := SerializeStructToJSON(&myStruct) + assert.NoError(t, err) + assert.Equal(t, data, outData) + + // Now, the same test with IEmbeddedValue not set. This simulates the + // case where extensions are present int the data but the struct needed + // to understand them has not been registered. + myStruct = MyStruct{} + + err = PopulateStructFromJSON(data, &myStruct) + assert.NoError(t, err) + + outData, err = SerializeStructToJSON(&myStruct) + assert.NoError(t, err) + assert.Equal(t, data, outData) + + // Make sure that, without caching, unknown values are simply ignored + // without causing errors. + noCache := MyStructNoCache{} + err = PopulateStructFromJSON(data, &noCache) + assert.NoError(t, err) + + expectedNoCache := []byte(`{"field0":"f1","field1":2}`) + + outData, err = SerializeStructToJSON(&noCache) + assert.NoError(t, err) + assert.Equal(t, expectedNoCache, outData) +} diff --git a/encoding/json.go b/encoding/json.go index c8666764..69f8f3b7 100644 --- a/encoding/json.go +++ b/encoding/json.go @@ -1,4 +1,4 @@ -// Copyright 2024 Contributors to the Veraison project. +// Copyright 2024-2025 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package encoding @@ -44,6 +44,13 @@ func doSerializeStructToJSON( continue } + _, ok := typeField.Tag.Lookup("field-cache") + if ok { + if err := addCachedFieldsToMapJSON(valField, rawMap); err != nil { + return err + } + } + tag, ok := typeField.Tag.Lookup("json") if !ok { continue @@ -118,6 +125,7 @@ func doPopulateStructFromJSON( } var embeds []embedded + var fieldCache reflect.Value for i := 0; i < structVal.NumField(); i++ { typeField := structType.Field(i) @@ -127,6 +135,12 @@ func doPopulateStructFromJSON( continue } + _, ok := typeField.Tag.Lookup("field-cache") + if ok { + fieldCache = valField + continue + } + tag, ok := typeField.Tag.Lookup("json") if !ok { continue @@ -176,7 +190,9 @@ func doPopulateStructFromJSON( } } - return nil + // Any remaining contents of rawMap will be added to the field cache, + // if current struct has one. + return updateFieldCacheJSON(fieldCache, rawMap) } // structFieldsJSON is a specialized implementation of "OrderedMap", where the @@ -359,3 +375,65 @@ func (o *TypeAndValue) UnmarshalJSON(data []byte) error { return nil } + +func addCachedFieldsToMapJSON(cacheField reflect.Value, rawMap *structFieldsJSON) error { + if !isMapStringAny(cacheField) { + return errors.New("field-cache does not appear to be a map[string]any") + } + + if !cacheField.IsValid() || cacheField.IsNil() { + // field cache was never set, so nothing to do + return nil + } + + for _, key := range cacheField.MapKeys() { + keyText := key.String() + + data, err := json.Marshal(cacheField.MapIndex(key).Interface()) + if err != nil { + return fmt.Errorf( + "error marshaling field-cache entry %q: %w", + keyText, + err, + ) + } + + if err := rawMap.Add(keyText, json.RawMessage(data)); err != nil { + return fmt.Errorf( + "could not add field-cache entry %q to serialization map: %w", + keyText, + err, + ) + } + } + + return nil +} + +func updateFieldCacheJSON(cacheField reflect.Value, rawMap *structFieldsJSON) error { + if !cacheField.IsValid() { + // current struct does not have a field-cache field + return nil + } + + if !isMapStringAny(cacheField) { + return errors.New("field-cache does not appear to be a map[string]any") + } + + if cacheField.IsNil() { + cacheField.Set(reflect.MakeMap(cacheField.Type())) + } + + for key, rawVal := range rawMap.Fields { + var val any + if err := json.Unmarshal(rawVal, &val); err != nil { + return fmt.Errorf("could not unmarshal key %q: %w", key, err) + } + + keyVal := reflect.ValueOf(key) + valVal := reflect.ValueOf(val) + cacheField.SetMapIndex(keyVal, valVal) + } + + return nil +} diff --git a/extensions/README.md b/extensions/README.md index d05c9029..37343432 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -127,6 +127,20 @@ method, passing itself as the parameter. You do not need to define this method unless you actually want to enforce some constraints (i.e., if you just want to define additional fields). +### Unknown extensions caching + +When unmarshaled data contains entries that do not correspond to fields inside +a registered extensions struct, their values get cached inside the `Extensions` +objects. If the containing object is later re-marshalled, cached values will be +included, so that unknown extensions are not lost. + +Cached extension values are not accessible via `Get*()` methods described +above, however they are available as `Extensions.Cached` map. + +If an extensions struct is registered after unmarshalling, it will be populated +with any now-recognized cached values, which will then be removed from the +cache. + ### Example The following example illustrates how to implement a map extension by extending diff --git a/extensions/extensions.go b/extensions/extensions.go index e41b3263..9b27b3d7 100644 --- a/extensions/extensions.go +++ b/extensions/extensions.go @@ -1,4 +1,4 @@ -// Copyright 2023-2024 Contributors to the Veraison project. +// Copyright 2023-2025 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package extensions @@ -24,6 +24,8 @@ type ExtensionValue struct { type Extensions struct { IMapValue `json:"extensions,omitempty"` + + Cached map[string]any `field-cache:"" cbor:"-" json:"-"` } func (o *Extensions) Register(exts IMapValue) { @@ -31,7 +33,15 @@ func (o *Extensions) Register(exts IMapValue) { panic("attempting to register a non-pointer IMapValue") } + // Ensure that the values of any existing extensions are preserved. + // The contents of the existing IMapValue (if there is one) are added + // to the cache, which is then applied to the new IMapValue. If the new + // IMapValue has fields corresponding to the old extensions, they will be + // populated into the new IMapValue; any old extensions that are not + // recognized by the new IMapValue will be cached. + updateMapFromInterface(&o.Cached, o.IMapValue) o.IMapValue = exts + updateInterfaceFromMap(o.IMapValue, o.Cached) } func (o *Extensions) HaveExtensions() bool { @@ -455,3 +465,89 @@ func newIMapValue(v IMapValue) IMapValue { return reflect.New(valType).Interface() } + +func updateMapFromInterface(mp *map[string]any, iface any) { // nolint: gocritic + if iface == nil { + return + } + + if *mp == nil { + *mp = make(map[string]any) + } + + ifType := reflect.TypeOf(iface) + ifVal := reflect.ValueOf(iface) + if ifType.Kind() == reflect.Pointer { + ifType = ifType.Elem() + ifVal = ifVal.Elem() + } + + for i := 0; i < ifVal.NumField(); i++ { + typeField := ifType.Field(i) + tag, ok := typeField.Tag.Lookup("cbor") + if !ok { + continue + } + + codePointText := strings.Split(tag, ",")[0] + valField := ifVal.Field(i) + if !valField.IsZero() { + (*mp)[codePointText] = valField.Interface() + } + } +} + +func updateInterfaceFromMap(iface any, m map[string]any) { + if iface == nil { + panic("nil interface") + } + + ifType := reflect.TypeOf(iface) + if ifType.Kind() != reflect.Pointer { + panic("interface must be a pointer") + } + + ifType = ifType.Elem() + ifVal := reflect.ValueOf(iface).Elem() + + for i := 0; i < ifVal.NumField(); i++ { + var fieldJSONTag, fieldCBORTag string + typeField := ifType.Field(i) + valField := ifVal.Field(i) + + tag, ok := typeField.Tag.Lookup("json") + if ok { + fieldJSONTag = strings.Split(tag, ",")[0] + } + + tag, ok = typeField.Tag.Lookup("cbor") + if ok { + fieldCBORTag = strings.Split(tag, ",")[0] + } + + mapKey := fieldJSONTag + rawMapVal, ok := m[mapKey] + if !ok { + mapKey = fieldCBORTag + rawMapVal, ok = m[mapKey] + if !ok { + continue + } + } + + mapVal := reflect.ValueOf(rawMapVal) + if !mapVal.Type().AssignableTo(typeField.Type) { + if mapVal.Type().ConvertibleTo(typeField.Type) { + mapVal = mapVal.Convert(typeField.Type) + } else { + // We cannot return an error here, and we don't + // want to panic, so we're just going to keep the + // entry in the cache. + continue + } + } + + valField.Set(mapVal) + delete(m, mapKey) + } +} diff --git a/extensions/extensions_test.go b/extensions/extensions_test.go index 8e48bf77..8d54d8e1 100644 --- a/extensions/extensions_test.go +++ b/extensions/extensions_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2024 Contributors to the Veraison project. +// Copyright 2023-2025 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package extensions @@ -7,11 +7,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/veraison/corim/encoding" ) type Entity struct { - EntityName string - Roles []int64 + EntityName string `cbor:"0,keyasint" json:"entity-name"` + Roles []int64 `cbor:"1,keyasint,omitempty" json:"roles,omitempty"` Extensions } @@ -168,3 +169,114 @@ func Test_Extensions_Values(t *testing.T) { vals = exts.Values() assert.Len(t, vals, 0) } + +func Test_Extensions_unknown_handling_CBOR(t *testing.T) { + // nolint: gocritic + data := []byte{ + 0xa4, // map(4) [entity] + + 0x00, // key: 0 [entity-name] + 0x63, // value: tstr(3) + 0x66, 0x6f, 0x6f, // "foo" + + 0x1, // key: 1 [roles] + 0x82, // value: array(2) + 0x01, 0x02, // [1, 2] + + 0x21, // key: -2 [extension(size)] + 0x07, // value: 7 + + 0x27, // key: -8 [extension()] + 0xf5, // value: true + } + + entity := Entity{} + err := encoding.PopulateStructFromCBOR(dm, data, &entity) + assert.NoError(t, err) + assert.Equal(t, "foo", entity.EntityName) + assert.Equal(t, []int64{1, 2}, entity.Roles) + assert.Equal(t, uint64(7), entity.Extensions.Cached["-2"]) // nolint: staticcheck + + // Check that the cached value has been populated into the + // newly-registered struct. + entity.Register(&TestExtensions{}) + assert.Equal(t, 7, entity.MustGetInt("size")) + + // Check that the populated value is no longer cached. + _, ok := entity.Extensions.Cached["-2"] // nolint: staticcheck + assert.False(t, ok) + + entity = Entity{} + entity.Register(&TestExtensions{}) + err = encoding.PopulateStructFromCBOR(dm, data, &entity) + assert.NoError(t, err) + + // If extensions were registered before unmarshalling, the value gets + // populated directly into the registered struct, bypassing the cache. + assert.Equal(t, 7, entity.MustGetInt("size")) + _, ok = entity.Extensions.Cached["-2"] // nolint: staticcheck + assert.False(t, ok) + + // Values for keys in in the registered struct still go into cache. + val, ok := entity.Extensions.Cached["-8"] // nolint: staticcheck + assert.True(t, ok) + assert.True(t, val.(bool)) + + encoded, err := encoding.SerializeStructToCBOR(em, &entity) + assert.NoError(t, err) + assert.Equal(t, data, encoded) +} + +func Test_Extensions_unknown_handling_JSON(t *testing.T) { + data := []byte(`{"entity-name":"foo","roles":[1,2],"size":7,"-8":true}`) + + entity := Entity{} + err := encoding.PopulateStructFromJSON(data, &entity) + assert.NoError(t, err) + assert.Equal(t, "foo", entity.EntityName) + assert.Equal(t, []int64{1, 2}, entity.Roles) + assert.Equal(t, float64(7), entity.Extensions.Cached["size"]) // nolint: staticcheck + + // since we only have the JSON field name "size", and we don't know + // what extension it corresponds to, CBOR encoding fails. + _, err = encoding.SerializeStructToCBOR(em, &entity) + assert.ErrorContains(t, err, "cached field name not an integer") + + // Check that the cached value has been populated into the + // newly-registered struct. + entity.Register(&TestExtensions{}) + assert.Equal(t, 7, entity.MustGetInt("size")) + + // Check that the populated value is no longer cached. + _, ok := entity.Extensions.Cached["size"] // nolint: staticcheck + assert.False(t, ok) + + // "size" has been recognized and removed form cache; we can now + // serialize it to CBOR as we now know its code point. The only + // remaining unknown extension has a name that can parse to an integer, + // so we can use that as the code point for CBOR, and serialization + // should succeed. + _, err = encoding.SerializeStructToCBOR(em, &entity) + assert.NoError(t, err) + + entity = Entity{} + entity.Register(&TestExtensions{}) + err = encoding.PopulateStructFromJSON(data, &entity) + assert.NoError(t, err) + + // If extensions were registered before unmarshalling, the value gets + // populated directly into the registered struct, bypassing the cache. + assert.Equal(t, 7, entity.MustGetInt("size")) + _, ok = entity.Extensions.Cached["size"] // nolint: staticcheck + assert.False(t, ok) + + // Values for keys in in the registered struct still go into cache. + val, ok := entity.Extensions.Cached["-8"] // nolint: staticcheck + assert.True(t, ok) + assert.True(t, val.(bool)) + + encoded, err := encoding.SerializeStructToJSON(&entity) + assert.NoError(t, err) + + assert.JSONEq(t, string(data), string(encoded)) +}