Skip to content

Commit 51544a5

Browse files
Merge pull request #127862 from dinhxuanvu/cbor-fuzz
KEP-4222: Add fuzz test for roundtrip unstructured objects to JSON/CBOR Kubernetes-commit: e673417529d9d2d2dd04f28f8437cbe1c403dfbe
2 parents 8a237ee + d143167 commit 51544a5

File tree

1 file changed

+326
-0
lines changed

1 file changed

+326
-0
lines changed

pkg/apis/meta/v1/unstructured/unstructured_test.go

+326
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,29 @@ limitations under the License.
1717
package unstructured_test
1818

1919
import (
20+
"bytes"
21+
"math/big"
2022
"math/rand"
23+
"os"
2124
"reflect"
25+
"strconv"
26+
"strings"
2227
"testing"
28+
"time"
2329

2430
"github.com/google/go-cmp/cmp"
31+
fuzz "github.com/google/gofuzz"
2532
"github.com/stretchr/testify/assert"
33+
2634
"k8s.io/apimachinery/pkg/api/apitesting/fuzzer"
2735
"k8s.io/apimachinery/pkg/api/equality"
2836
metafuzzer "k8s.io/apimachinery/pkg/apis/meta/fuzzer"
2937
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3038
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3139
"k8s.io/apimachinery/pkg/runtime"
3240
"k8s.io/apimachinery/pkg/runtime/serializer"
41+
cborserializer "k8s.io/apimachinery/pkg/runtime/serializer/cbor"
42+
jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
3343
)
3444

3545
func TestNilUnstructuredContent(t *testing.T) {
@@ -118,6 +128,18 @@ func TestUnstructuredMetadataOmitempty(t *testing.T) {
118128
}
119129
}
120130

131+
// TestRoundTripJSONCBORUnstructured performs fuzz testing for roundtrip for
132+
// unstructured object between JSON and CBOR
133+
func TestRoundTripJSONCBORUnstructured(t *testing.T) {
134+
roundtripType[*unstructured.Unstructured](t)
135+
}
136+
137+
// TestRoundTripJSONCBORUnstructuredList performs fuzz testing for roundtrip for
138+
// unstructuredList object between JSON and CBOR
139+
func TestRoundTripJSONCBORUnstructuredList(t *testing.T) {
140+
roundtripType[*unstructured.UnstructuredList](t)
141+
}
142+
121143
func setObjectMeta(u *unstructured.Unstructured, objectMeta *metav1.ObjectMeta) error {
122144
if objectMeta == nil {
123145
unstructured.RemoveNestedField(u.UnstructuredContent(), "metadata")
@@ -148,3 +170,307 @@ func setObjectMetaUsingAccessors(u, uCopy *unstructured.Unstructured) {
148170
uCopy.SetFinalizers(u.GetFinalizers())
149171
uCopy.SetManagedFields(u.GetManagedFields())
150172
}
173+
174+
// roundtripType performs fuzz testing for roundtrip conversion for
175+
// unstructured or unstructuredList object between two formats (A and B) in forward
176+
// and backward directions
177+
// Original and final unstructured/list are compared along with all intermediate ones
178+
func roundtripType[U runtime.Unstructured](t *testing.T) {
179+
scheme := runtime.NewScheme()
180+
fuzzer := fuzzer.FuzzerFor(fuzzer.MergeFuzzerFuncs(metafuzzer.Funcs, unstructuredFuzzerFuncs), rand.NewSource(getSeed(t)), serializer.NewCodecFactory(scheme))
181+
182+
jS := jsonserializer.NewSerializerWithOptions(jsonserializer.DefaultMetaFactory, scheme, scheme, jsonserializer.SerializerOptions{})
183+
cS := cborserializer.NewSerializer(scheme, scheme)
184+
185+
for i := 0; i < 50; i++ {
186+
original := reflect.New(reflect.TypeFor[U]().Elem()).Interface().(runtime.Unstructured)
187+
fuzzer.Fuzz(original)
188+
// unstructured -> JSON > unstructured > CBOR -> unstructured -> JSON -> unstructured
189+
roundtrip(t, original, jS, cS)
190+
// unstructured -> CBOR > unstructured > JSON -> unstructured -> CBOR -> unstructured
191+
roundtrip(t, original, cS, jS)
192+
}
193+
}
194+
195+
// roundtrip tests that an Unstructured object roundtrips faithfully along the
196+
// sequence Unstructured -> A -> Unstructured -> B -> Unstructured -> A -> Unstructured,
197+
// given serializers for two encodings A and B. The final object and both intermediate
198+
// objects must all be equal to the original.
199+
func roundtrip(t *testing.T, original runtime.Unstructured, a, b runtime.Serializer) {
200+
var buf bytes.Buffer
201+
202+
buf.Reset()
203+
// (original) Unstructured -> A
204+
if err := a.Encode(original, &buf); err != nil {
205+
t.Fatalf("error encoding original unstructured to A: %v", err)
206+
}
207+
// A -> intermediate unstructured
208+
uA := reflect.New(reflect.TypeOf(original).Elem()).Interface().(runtime.Object)
209+
uA, _, err := a.Decode(buf.Bytes(), nil, uA)
210+
if err != nil {
211+
t.Fatalf("error decoding A to unstructured: %v", err)
212+
}
213+
214+
// Compare original unstructured vs intermediate unstructured
215+
tmp, ok := uA.(runtime.Unstructured)
216+
if !ok {
217+
t.Fatalf("unexpected type %T for unstructured", tmp)
218+
}
219+
if !unstructuredEqual(t, original, uA.(runtime.Unstructured)) {
220+
t.Fatalf("original unstructured differed from unstructured via A: %v", cmp.Diff(original, uA))
221+
}
222+
223+
buf.Reset()
224+
// intermediate unstructured -> B
225+
if err := b.Encode(uA, &buf); err != nil {
226+
t.Fatalf("error encoding unstructured to B: %v", err)
227+
}
228+
// B -> intermediate unstructured
229+
uB := reflect.New(reflect.TypeOf(original).Elem()).Interface().(runtime.Object)
230+
uB, _, err = b.Decode(buf.Bytes(), nil, uB)
231+
if err != nil {
232+
t.Fatalf("error decoding B to unstructured: %v", err)
233+
}
234+
235+
// compare original vs intermediate unstructured
236+
tmp, ok = uB.(runtime.Unstructured)
237+
if !ok {
238+
t.Fatalf("unexpected type %T for unstructured", tmp)
239+
}
240+
if !unstructuredEqual(t, original, uB.(runtime.Unstructured)) {
241+
t.Fatalf("unstructured via A differed from unstructured via B: %v", cmp.Diff(original, uB))
242+
}
243+
244+
// intermediate unstructured -> A
245+
buf.Reset()
246+
if err := a.Encode(uB, &buf); err != nil {
247+
t.Fatalf("error encoding unstructured to A: %v", err)
248+
}
249+
// A -> final unstructured
250+
final := reflect.New(reflect.TypeOf(original).Elem()).Interface().(runtime.Object)
251+
final, _, err = a.Decode(buf.Bytes(), nil, final)
252+
if err != nil {
253+
t.Fatalf("error decoding A to unstructured: %v", err)
254+
}
255+
256+
// Compare original unstructured vs final unstructured
257+
tmp, ok = final.(runtime.Unstructured)
258+
if !ok {
259+
t.Fatalf("unexpected type %T for unstructured", tmp)
260+
}
261+
if !unstructuredEqual(t, original, final.(runtime.Unstructured)) {
262+
t.Errorf("object changed during unstructured->A->unstructured->B->unstructured roundtrip, diff: %s", cmp.Diff(original, final))
263+
}
264+
}
265+
266+
func getSeed(t *testing.T) int64 {
267+
seed := int64(time.Now().Nanosecond())
268+
if override := os.Getenv("TEST_RAND_SEED"); len(override) > 0 {
269+
overrideSeed, err := strconv.ParseInt(override, 10, 64)
270+
if err != nil {
271+
t.Fatal(err)
272+
}
273+
seed = overrideSeed
274+
t.Logf("using overridden seed: %d", seed)
275+
} else {
276+
t.Logf("seed (override with TEST_RAND_SEED if desired): %d", seed)
277+
}
278+
return seed
279+
}
280+
281+
const (
282+
maxUnstructuredDepth = 64
283+
maxUnstructuredFanOut = 5
284+
)
285+
286+
func unstructuredFuzzerFuncs(codecs serializer.CodecFactory) []interface{} {
287+
return []interface{}{
288+
func(u *unstructured.Unstructured, c fuzz.Continue) {
289+
obj := make(map[string]interface{})
290+
obj["apiVersion"] = generateValidAPIVersionString(c)
291+
obj["kind"] = generateNonEmptyString(c)
292+
for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- {
293+
obj[c.RandString()] = generateRandomTypeValue(maxUnstructuredDepth, c)
294+
}
295+
u.Object = obj
296+
},
297+
func(ul *unstructured.UnstructuredList, c fuzz.Continue) {
298+
obj := make(map[string]interface{})
299+
obj["apiVersion"] = generateValidAPIVersionString(c)
300+
obj["kind"] = generateNonEmptyString(c)
301+
for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- {
302+
obj[c.RandString()] = generateRandomTypeValue(maxUnstructuredDepth, c)
303+
}
304+
for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- {
305+
var item = unstructured.Unstructured{}
306+
c.Fuzz(&item)
307+
ul.Items = append(ul.Items, item)
308+
}
309+
ul.Object = obj
310+
},
311+
}
312+
}
313+
314+
func generateNonEmptyString(c fuzz.Continue) string {
315+
temp := c.RandString()
316+
for len(temp) == 0 {
317+
temp = c.RandString()
318+
}
319+
return temp
320+
}
321+
322+
// generateNonEmptyNoSlashString generates a non-empty string without any slashes
323+
func generateNonEmptyNoSlashString(c fuzz.Continue) string {
324+
temp := strings.ReplaceAll(generateNonEmptyString(c), "/", "")
325+
for len(temp) == 0 {
326+
temp = strings.ReplaceAll(generateNonEmptyString(c), "/", "")
327+
}
328+
return temp
329+
}
330+
331+
// generateValidAPIVersionString generates valid apiVersion string with formats:
332+
// <string>/<string> or <string>
333+
func generateValidAPIVersionString(c fuzz.Continue) string {
334+
if c.RandBool() {
335+
return generateNonEmptyNoSlashString(c) + "/" + generateNonEmptyNoSlashString(c)
336+
} else {
337+
return generateNonEmptyNoSlashString(c)
338+
}
339+
}
340+
341+
// generateRandomTypeValue generates fuzzed valid JSON data types:
342+
// 1. numbers (float64, int64)
343+
// 2. string (utf-8 encodings)
344+
// 3. boolean
345+
// 4. array ([]interface{})
346+
// 5. object (map[string]interface{})
347+
// 6. null
348+
// Decoding into unstructured can only produce a nil interface{} value or the
349+
// concrete types map[string]interface{}, []interface{}, int64, float64, string, and bool
350+
// If a value of other types is put into an unstructured, it will roundtrip
351+
// to one of the above list of supported types. For example, if Time type is used,
352+
// it will be encoded into a RFC 3339 format string such as "2001-02-03T12:34:56Z"
353+
// and when decoding into Unstructured, there is no information to indicate
354+
// that this string was originally produced by encoding a metav1.Time.
355+
// All external-versioned builtin types are exercised through RoundtripToUnstructured
356+
// in apitesting package. Types like metav1.Time are implicitly being exercised
357+
// because they appear as fields in those types.
358+
func generateRandomTypeValue(depth int, c fuzz.Continue) interface{} {
359+
t := c.Rand.Intn(120)
360+
// If the max depth for unstructured is reached, only add non-recursive types
361+
// which is 20+ in range
362+
if depth == 0 {
363+
t = 20 + c.Rand.Intn(120-20)
364+
}
365+
366+
switch {
367+
case t < 10:
368+
item := make([]interface{}, c.Intn(maxUnstructuredFanOut))
369+
for k := range item {
370+
item[k] = generateRandomTypeValue(depth-1, c)
371+
}
372+
return item
373+
case t < 20:
374+
item := map[string]interface{}{}
375+
for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- {
376+
item[c.RandString()] = generateRandomTypeValue(depth-1, c)
377+
}
378+
return item
379+
case t < 40:
380+
// Only valid UTF-8 encodings
381+
var item string
382+
c.Fuzz(&item)
383+
return item
384+
case t < 60:
385+
var item int64
386+
c.Fuzz(&item)
387+
return item
388+
case t < 80:
389+
var item bool
390+
c.Fuzz(&item)
391+
return item
392+
case t < 100:
393+
return c.Rand.NormFloat64()
394+
case t < 120:
395+
return nil
396+
default:
397+
panic("invalid case")
398+
}
399+
}
400+
401+
func unstructuredEqual(t *testing.T, a, b runtime.Unstructured) bool {
402+
return anyEqual(t, a.UnstructuredContent(), b.UnstructuredContent())
403+
}
404+
405+
// numberEqual asserts equality of two numbers which one is int64 and one is float64
406+
// In JSON, a non-decimal float64 is converted to int64 automatically in case the
407+
// float64 fits into int64 range. Otherwise, the non-decimal float64 remains a float.
408+
// As a result, this func does an int64 to float64 conversion using math/big package
409+
// to ensure the conversion is lossless before comparison.
410+
func numberEqual(a int64, b float64) bool {
411+
// Ensure roundtrip int64 to float64 conversion is lossless
412+
f, accuracy := big.NewInt(a).Float64()
413+
if accuracy == big.Exact {
414+
// Distinction between int64 and float64 is not preserved during JSON roundtrip for all numbers.
415+
return f == b
416+
}
417+
return false
418+
}
419+
420+
func anyEqual(t *testing.T, a, b interface{}) bool {
421+
switch b.(type) {
422+
case nil, bool, string, int64, float64, []interface{}, map[string]interface{}:
423+
default:
424+
t.Fatalf("unexpected value %v of type %T", b, b)
425+
}
426+
427+
switch ac := a.(type) {
428+
case nil, bool, string:
429+
return ac == b
430+
case int64:
431+
if bc, ok := b.(float64); ok {
432+
return numberEqual(ac, bc)
433+
}
434+
return ac == b
435+
case float64:
436+
if bc, ok := b.(int64); ok {
437+
return numberEqual(bc, ac)
438+
}
439+
return ac == b
440+
case []interface{}:
441+
bc, ok := b.([]interface{})
442+
if !ok {
443+
return false
444+
}
445+
if len(ac) != len(bc) {
446+
return false
447+
}
448+
for i, aa := range ac {
449+
if !anyEqual(t, aa, bc[i]) {
450+
return false
451+
}
452+
}
453+
return true
454+
case map[string]interface{}:
455+
bc, ok := b.(map[string]interface{})
456+
if !ok {
457+
return false
458+
}
459+
if len(ac) != len(bc) {
460+
return false
461+
}
462+
for k, aa := range ac {
463+
bb, ok := bc[k]
464+
if !ok {
465+
return false
466+
}
467+
if !anyEqual(t, aa, bb) {
468+
return false
469+
}
470+
}
471+
return true
472+
default:
473+
t.Fatalf("unexpected value %v of type %T", a, a)
474+
}
475+
return true
476+
}

0 commit comments

Comments
 (0)