From 32c5cbcf59a797eea6cb40a902185d4e04b71647 Mon Sep 17 00:00:00 2001 From: Sergei Trofimov Date: Thu, 11 Dec 2025 12:13:54 +0000 Subject: [PATCH 1/3] feat: unknown field caching when decoding Allow caching of unknown fields during PopulateStructFrom(CBOR|JSON). The cache is then used during SerializeStructTo(CBOR|JSON). This ensures that any map entries in the source data that do not correspond to target struct fields are preserved across decode/encode cycle. This feature is enabled by adding a field to a struct tagged with `field-cache:""`. This field must be of type map[string]any. When PopulateStructFrom* functions encounter input entries that do not correspond to a field in the target struct, they will be added to the field-cache map instead. Analogously, when SerializeStructTo* functions see a field-cache map, they will add its entries to the output. This scheme has some (hopefully, obvious) limitations: - field-cache field's tag must also contain `cbor:"-" json:"-"` to make sure that the field itself will be ignored by serializers. - if a struct is unmarshaled from JSON, any unknown field names must be "stringified" integers, otherwise it is impossible to obtain the corresponding CBOR code point mapping for the name. Signed-off-by: Sergei Trofimov --- encoding/cbor.go | 90 +++++++++++++++++- encoding/embedded.go | 20 +++- encoding/field_cache_test.go | 179 +++++++++++++++++++++++++++++++++++ encoding/json.go | 82 +++++++++++++++- 4 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 encoding/field_cache_test.go diff --git a/encoding/cbor.go b/encoding/cbor.go index afe76feb..c2eb0461 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 @@ -49,6 +49,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 +136,7 @@ func doPopulateStructFromCBOR( } var embeds []embedded + var fieldCache reflect.Value for i := 0; i < structVal.NumField(); i++ { typeField := structType.Field(i) @@ -138,6 +146,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 +206,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 @@ -414,3 +430,73 @@ 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 +} 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 +} From 31325d49cf2b8b3324fc4700972028789530ff7e Mon Sep 17 00:00:00 2001 From: Sergei Trofimov Date: Thu, 11 Dec 2025 18:00:24 +0000 Subject: [PATCH 2/3] fix: enforce lexicographic ordering when encoding CBOR Up to this point, we only had deterministic encoding for structs. Fields were encoded in the order they appeared. This fix ensures that fields are encoded in lexicographic order as required by CDE spec: https://www.ietf.org/archive/id/draft-ietf-cbor-cde-13.html#name-the-lexicographic-map-sorti (Note: we typically define fields in the order of their code points, so in practice, we have been _mostly_ compliant with CDE; but this was not guaranteed, and deviations were possible, especially when extensions are involved.) Signed-off-by: Sergei Trofimov --- encoding/cbor.go | 28 +++++++++++++++ encoding/cbor_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/encoding/cbor.go b/encoding/cbor.go index c2eb0461..30f07bb4 100644 --- a/encoding/cbor.go +++ b/encoding/cbor.go @@ -9,6 +9,7 @@ import ( "fmt" "math" "reflect" + "sort" "strconv" "strings" @@ -285,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 { @@ -500,3 +502,29 @@ func updateFieldCacheCBOR(dm cbor.DecMode, cacheField reflect.Value, rawMap *str 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) + }) + } +} From 541f338ecb722bc9b75324089c91ea3523a53ba3 Mon Sep 17 00:00:00 2001 From: Sergei Trofimov Date: Thu, 11 Dec 2025 18:07:34 +0000 Subject: [PATCH 3/3] feat: implement extension caching Extensions objects will now cache any extensions they don't recognize (that don't correspond to a field inside their registered IMapValue) when deserializing values. This means CORIMs with extensions remain stable when deserialized and then re-serialized using structs without registered extensions. Cached extensions are only used during marshalling, and will not be returned when calling the Get* methods. However they can be accessed directly via Extensions.Cached field. When an IMapValue struct is registered, cached values are scanned, and the new struct is populated with cached values, which are then removed form the cache. When registering a new struct when there is an existing IMapValue, the non-zero-value fields of the old IMapValue are stored in the cache. Signed-off-by: Sergei Trofimov --- extensions/README.md | 14 ++++ extensions/extensions.go | 98 +++++++++++++++++++++++++++- extensions/extensions_test.go | 118 +++++++++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 4 deletions(-) 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)) +}