Skip to content

Commit 32c5cbc

Browse files
committed
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 <[email protected]>
1 parent 599cca9 commit 32c5cbc

File tree

4 files changed

+366
-5
lines changed

4 files changed

+366
-5
lines changed

encoding/cbor.go

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2023-2024 Contributors to the Veraison project.
1+
// Copyright 2023-2025 Contributors to the Veraison project.
22
// SPDX-License-Identifier: Apache-2.0
33

44
package encoding
@@ -49,6 +49,13 @@ func doSerializeStructToCBOR(
4949
continue
5050
}
5151

52+
_, ok := typeField.Tag.Lookup("field-cache")
53+
if ok {
54+
if err := addCachedFieldsToMapCBOR(em, valField, rawMap); err != nil {
55+
return err
56+
}
57+
}
58+
5259
tag, ok := typeField.Tag.Lookup("cbor")
5360
if !ok {
5461
continue
@@ -129,6 +136,7 @@ func doPopulateStructFromCBOR(
129136
}
130137

131138
var embeds []embedded
139+
var fieldCache reflect.Value
132140

133141
for i := 0; i < structVal.NumField(); i++ {
134142
typeField := structType.Field(i)
@@ -138,6 +146,12 @@ func doPopulateStructFromCBOR(
138146
continue
139147
}
140148

149+
_, ok := typeField.Tag.Lookup("field-cache")
150+
if ok {
151+
fieldCache = valField
152+
continue
153+
}
154+
141155
tag, ok := typeField.Tag.Lookup("cbor")
142156
if !ok {
143157
continue
@@ -192,7 +206,9 @@ func doPopulateStructFromCBOR(
192206
}
193207
}
194208

195-
return nil
209+
// Any remaining contents of rawMap will be added to the field cache,
210+
// if current struct has one.
211+
return updateFieldCacheCBOR(dm, fieldCache, rawMap)
196212
}
197213

198214
// structFieldsCBOR is a specialized implementation of "OrderedMap", where the
@@ -414,3 +430,73 @@ func processAdditionalInfo(
414430

415431
return mapLen, rest, nil
416432
}
433+
434+
func addCachedFieldsToMapCBOR(em cbor.EncMode, cacheField reflect.Value, rawMap *structFieldsCBOR) error {
435+
if !isMapStringAny(cacheField) {
436+
return errors.New("field-cache does not appear to be a map[string]any")
437+
}
438+
439+
if !cacheField.IsValid() || cacheField.IsNil() {
440+
// field cache was never set, so nothing to do
441+
return nil
442+
}
443+
444+
for _, key := range cacheField.MapKeys() {
445+
keyText := key.String()
446+
keyInt, err := strconv.Atoi(keyText)
447+
if err != nil {
448+
return fmt.Errorf(
449+
"cached field name not an integer (cannot encode to CBOR): %s",
450+
keyText,
451+
)
452+
}
453+
454+
data, err := em.Marshal(cacheField.MapIndex(key).Interface())
455+
if err != nil {
456+
return fmt.Errorf(
457+
"error marshaling field-cache entry %q: %w",
458+
keyText,
459+
err,
460+
)
461+
}
462+
463+
if err := rawMap.Add(keyInt, cbor.RawMessage(data)); err != nil {
464+
return fmt.Errorf(
465+
"could not add field-cache entry %q to serialization map: %w",
466+
keyText,
467+
err,
468+
)
469+
}
470+
}
471+
472+
return nil
473+
}
474+
475+
func updateFieldCacheCBOR(dm cbor.DecMode, cacheField reflect.Value, rawMap *structFieldsCBOR) error {
476+
if !cacheField.IsValid() {
477+
// current struct does not have a field-cache field
478+
return nil
479+
}
480+
481+
if !isMapStringAny(cacheField) {
482+
return errors.New("field-cache does not appear to be a map[string]any")
483+
}
484+
485+
if cacheField.IsNil() {
486+
cacheField.Set(reflect.MakeMap(cacheField.Type()))
487+
}
488+
489+
for key, rawVal := range rawMap.Fields {
490+
var val any
491+
if err := dm.Unmarshal(rawVal, &val); err != nil {
492+
return fmt.Errorf("could not unmarshal key %d: %w", key, err)
493+
}
494+
495+
keyText := fmt.Sprint(key)
496+
keyVal := reflect.ValueOf(keyText)
497+
valVal := reflect.ValueOf(val)
498+
cacheField.SetMapIndex(keyVal, valVal)
499+
}
500+
501+
return nil
502+
}

encoding/embedded.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 Contributors to the Veraison project.
1+
// Copyright 2024-2025 Contributors to the Veraison project.
22
// SPDX-License-Identifier: Apache-2.0
33
package encoding
44

@@ -49,3 +49,21 @@ func collectEmbedded(
4949

5050
return false
5151
}
52+
53+
// isMapStringAny returns true iff the provided value, v, is of type
54+
// map[string]any.
55+
func isMapStringAny(v reflect.Value) bool {
56+
if v.Kind() != reflect.Map {
57+
return false
58+
}
59+
60+
if v.Type().Key().Kind() != reflect.String {
61+
return false
62+
}
63+
64+
if v.Type().Elem().Kind() != reflect.Interface {
65+
return false
66+
}
67+
68+
return true
69+
}

encoding/field_cache_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright 2025 Contributors to the Veraison project.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package encoding
4+
5+
import (
6+
"testing"
7+
8+
cbor "github.com/fxamacker/cbor/v2"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// The following structs emulate the embedding pattern used for extensions
13+
14+
type IEmbeddedValue any
15+
16+
type Embedded struct {
17+
IEmbeddedValue `json:"embedded,omitempty"`
18+
19+
FieldCache map[string]any `field-cache:"" cbor:"-" json:"-"`
20+
}
21+
22+
type MyStruct struct {
23+
Field0 string `cbor:"0,keyasint,omitempty" json:"field0,omitempty"`
24+
Field1 int `cbor:"1,keyasint,omitempty" json:"field1,omitempty"`
25+
26+
Embedded
27+
}
28+
29+
type MyEmbed struct {
30+
Foo string `cbor:"-1,keyasint,omitempty" json:"foo,omitempty"`
31+
}
32+
33+
type EmbeddedNoCache struct {
34+
IEmbeddedValue `json:"embedded,omitempty"`
35+
}
36+
37+
type MyStructNoCache struct {
38+
Field0 string `cbor:"0,keyasint,omitempty" json:"field0,omitempty"`
39+
Field1 int `cbor:"1,keyasint,omitempty" json:"field1,omitempty"`
40+
41+
EmbeddedNoCache
42+
}
43+
44+
func mustInitEncMode() cbor.EncMode {
45+
encOpt := cbor.EncOptions{
46+
Sort: cbor.SortCoreDeterministic,
47+
IndefLength: cbor.IndefLengthForbidden,
48+
TimeTag: cbor.EncTagRequired,
49+
}
50+
51+
em, err := encOpt.EncMode()
52+
if err != nil {
53+
panic(err)
54+
}
55+
56+
return em
57+
}
58+
59+
func mustInitDecMode() cbor.DecMode {
60+
decOpt := cbor.DecOptions{
61+
IndefLength: cbor.IndefLengthAllowed,
62+
}
63+
64+
dm, err := decOpt.DecMode()
65+
if err != nil {
66+
panic(err)
67+
}
68+
69+
return dm
70+
}
71+
72+
func Test_preserve_unknown_embeds_CBOR(t *testing.T) {
73+
// nolint: gocritic
74+
data := []byte{
75+
0xa3, // map(3)
76+
77+
0x00, // key: 0
78+
0x62, // value: tstr(2)
79+
0x66, 0x31, // "f1"
80+
81+
0x01, // key: 1
82+
0x02, // value: 2
83+
84+
0x20, // key: -1
85+
0x63, // value: tstr(3)
86+
0x62, 0x61, 0x72, // "bar"
87+
}
88+
89+
em := mustInitEncMode()
90+
dm := mustInitDecMode()
91+
92+
// First, a sanity test to make sure that embedded data
93+
// is preserved when there is a concrete struct to
94+
// contain it.
95+
embed := MyEmbed{}
96+
myStruct := MyStruct{Embedded: Embedded{IEmbeddedValue: &embed, FieldCache: map[string]any{}}}
97+
98+
err := PopulateStructFromCBOR(dm, data, &myStruct)
99+
assert.NoError(t, err)
100+
101+
outData, err := SerializeStructToCBOR(em, &myStruct)
102+
assert.NoError(t, err)
103+
assert.Equal(t, data, outData)
104+
105+
// Now, the same test with IEmbeddedValue not set. This simulates the
106+
// case where extensions are present in the data but the struct needed
107+
// to understand them has not been registered.
108+
myStruct = MyStruct{}
109+
110+
err = PopulateStructFromCBOR(dm, data, &myStruct)
111+
assert.NoError(t, err)
112+
113+
outData, err = SerializeStructToCBOR(em, &myStruct)
114+
assert.NoError(t, err)
115+
assert.Equal(t, data, outData)
116+
117+
// Make sure that, without caching, unknown values are simply ignored
118+
// without causing errors.
119+
noCache := MyStructNoCache{}
120+
err = PopulateStructFromCBOR(dm, data, &noCache)
121+
assert.NoError(t, err)
122+
123+
// nolint: gocritic
124+
expectedNoEmbed := []byte{
125+
0xa2, // map(2)
126+
127+
0x00, // key: 0
128+
0x62, // value: tstr(2)
129+
0x66, 0x31, // "f1"
130+
131+
0x01, // key: 1
132+
0x02, // value: 2
133+
}
134+
135+
outData, err = SerializeStructToCBOR(em, &noCache)
136+
assert.NoError(t, err)
137+
assert.Equal(t, expectedNoEmbed, outData)
138+
}
139+
140+
func Test_preserve_unknown_embeds_JSON(t *testing.T) {
141+
data := []byte(`{"field0":"f1","field1":2,"foo":"bar"}`)
142+
143+
// First, a sanity test to make sure that embedded data
144+
// is presevered when there is a concrete struct to
145+
// contain it.
146+
embed := MyEmbed{}
147+
myStruct := MyStruct{Embedded: Embedded{IEmbeddedValue: &embed, FieldCache: map[string]any{}}}
148+
149+
err := PopulateStructFromJSON(data, &myStruct)
150+
assert.NoError(t, err)
151+
152+
outData, err := SerializeStructToJSON(&myStruct)
153+
assert.NoError(t, err)
154+
assert.Equal(t, data, outData)
155+
156+
// Now, the same test with IEmbeddedValue not set. This simulates the
157+
// case where extensions are present int the data but the struct needed
158+
// to understand them has not been registered.
159+
myStruct = MyStruct{}
160+
161+
err = PopulateStructFromJSON(data, &myStruct)
162+
assert.NoError(t, err)
163+
164+
outData, err = SerializeStructToJSON(&myStruct)
165+
assert.NoError(t, err)
166+
assert.Equal(t, data, outData)
167+
168+
// Make sure that, without caching, unknown values are simply ignored
169+
// without causing errors.
170+
noCache := MyStructNoCache{}
171+
err = PopulateStructFromJSON(data, &noCache)
172+
assert.NoError(t, err)
173+
174+
expectedNoCache := []byte(`{"field0":"f1","field1":2}`)
175+
176+
outData, err = SerializeStructToJSON(&noCache)
177+
assert.NoError(t, err)
178+
assert.Equal(t, expectedNoCache, outData)
179+
}

0 commit comments

Comments
 (0)