diff --git a/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructured_test.go b/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructured_test.go index 54e14ed63b6..3eda144f8d1 100644 --- a/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructured_test.go +++ b/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructured_test.go @@ -17,12 +17,20 @@ limitations under the License. package unstructured_test import ( + "bytes" + "math/big" "math/rand" + "os" "reflect" + "strconv" + "strings" "testing" + "time" "github.com/google/go-cmp/cmp" + fuzz "github.com/google/gofuzz" "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" "k8s.io/apimachinery/pkg/api/equality" metafuzzer "k8s.io/apimachinery/pkg/apis/meta/fuzzer" @@ -30,6 +38,8 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" + cborserializer "k8s.io/apimachinery/pkg/runtime/serializer/cbor" + jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json" ) func TestNilUnstructuredContent(t *testing.T) { @@ -118,6 +128,18 @@ func TestUnstructuredMetadataOmitempty(t *testing.T) { } } +// TestRoundTripJSONCBORUnstructured performs fuzz testing for roundtrip for +// unstructured object between JSON and CBOR +func TestRoundTripJSONCBORUnstructured(t *testing.T) { + roundtripType[*unstructured.Unstructured](t) +} + +// TestRoundTripJSONCBORUnstructuredList performs fuzz testing for roundtrip for +// unstructuredList object between JSON and CBOR +func TestRoundTripJSONCBORUnstructuredList(t *testing.T) { + roundtripType[*unstructured.UnstructuredList](t) +} + func setObjectMeta(u *unstructured.Unstructured, objectMeta *metav1.ObjectMeta) error { if objectMeta == nil { unstructured.RemoveNestedField(u.UnstructuredContent(), "metadata") @@ -148,3 +170,307 @@ func setObjectMetaUsingAccessors(u, uCopy *unstructured.Unstructured) { uCopy.SetFinalizers(u.GetFinalizers()) uCopy.SetManagedFields(u.GetManagedFields()) } + +// roundtripType performs fuzz testing for roundtrip conversion for +// unstructured or unstructuredList object between two formats (A and B) in forward +// and backward directions +// Original and final unstructured/list are compared along with all intermediate ones +func roundtripType[U runtime.Unstructured](t *testing.T) { + scheme := runtime.NewScheme() + fuzzer := fuzzer.FuzzerFor(fuzzer.MergeFuzzerFuncs(metafuzzer.Funcs, unstructuredFuzzerFuncs), rand.NewSource(getSeed(t)), serializer.NewCodecFactory(scheme)) + + jS := jsonserializer.NewSerializerWithOptions(jsonserializer.DefaultMetaFactory, scheme, scheme, jsonserializer.SerializerOptions{}) + cS := cborserializer.NewSerializer(scheme, scheme) + + for i := 0; i < 50; i++ { + original := reflect.New(reflect.TypeFor[U]().Elem()).Interface().(runtime.Unstructured) + fuzzer.Fuzz(original) + // unstructured -> JSON > unstructured > CBOR -> unstructured -> JSON -> unstructured + roundtrip(t, original, jS, cS) + // unstructured -> CBOR > unstructured > JSON -> unstructured -> CBOR -> unstructured + roundtrip(t, original, cS, jS) + } +} + +// roundtrip tests that an Unstructured object roundtrips faithfully along the +// sequence Unstructured -> A -> Unstructured -> B -> Unstructured -> A -> Unstructured, +// given serializers for two encodings A and B. The final object and both intermediate +// objects must all be equal to the original. +func roundtrip(t *testing.T, original runtime.Unstructured, a, b runtime.Serializer) { + var buf bytes.Buffer + + buf.Reset() + // (original) Unstructured -> A + if err := a.Encode(original, &buf); err != nil { + t.Fatalf("error encoding original unstructured to A: %v", err) + } + // A -> intermediate unstructured + uA := reflect.New(reflect.TypeOf(original).Elem()).Interface().(runtime.Object) + uA, _, err := a.Decode(buf.Bytes(), nil, uA) + if err != nil { + t.Fatalf("error decoding A to unstructured: %v", err) + } + + // Compare original unstructured vs intermediate unstructured + tmp, ok := uA.(runtime.Unstructured) + if !ok { + t.Fatalf("unexpected type %T for unstructured", tmp) + } + if !unstructuredEqual(t, original, uA.(runtime.Unstructured)) { + t.Fatalf("original unstructured differed from unstructured via A: %v", cmp.Diff(original, uA)) + } + + buf.Reset() + // intermediate unstructured -> B + if err := b.Encode(uA, &buf); err != nil { + t.Fatalf("error encoding unstructured to B: %v", err) + } + // B -> intermediate unstructured + uB := reflect.New(reflect.TypeOf(original).Elem()).Interface().(runtime.Object) + uB, _, err = b.Decode(buf.Bytes(), nil, uB) + if err != nil { + t.Fatalf("error decoding B to unstructured: %v", err) + } + + // compare original vs intermediate unstructured + tmp, ok = uB.(runtime.Unstructured) + if !ok { + t.Fatalf("unexpected type %T for unstructured", tmp) + } + if !unstructuredEqual(t, original, uB.(runtime.Unstructured)) { + t.Fatalf("unstructured via A differed from unstructured via B: %v", cmp.Diff(original, uB)) + } + + // intermediate unstructured -> A + buf.Reset() + if err := a.Encode(uB, &buf); err != nil { + t.Fatalf("error encoding unstructured to A: %v", err) + } + // A -> final unstructured + final := reflect.New(reflect.TypeOf(original).Elem()).Interface().(runtime.Object) + final, _, err = a.Decode(buf.Bytes(), nil, final) + if err != nil { + t.Fatalf("error decoding A to unstructured: %v", err) + } + + // Compare original unstructured vs final unstructured + tmp, ok = final.(runtime.Unstructured) + if !ok { + t.Fatalf("unexpected type %T for unstructured", tmp) + } + if !unstructuredEqual(t, original, final.(runtime.Unstructured)) { + t.Errorf("object changed during unstructured->A->unstructured->B->unstructured roundtrip, diff: %s", cmp.Diff(original, final)) + } +} + +func getSeed(t *testing.T) int64 { + seed := int64(time.Now().Nanosecond()) + if override := os.Getenv("TEST_RAND_SEED"); len(override) > 0 { + overrideSeed, err := strconv.ParseInt(override, 10, 64) + if err != nil { + t.Fatal(err) + } + seed = overrideSeed + t.Logf("using overridden seed: %d", seed) + } else { + t.Logf("seed (override with TEST_RAND_SEED if desired): %d", seed) + } + return seed +} + +const ( + maxUnstructuredDepth = 64 + maxUnstructuredFanOut = 5 +) + +func unstructuredFuzzerFuncs(codecs serializer.CodecFactory) []interface{} { + return []interface{}{ + func(u *unstructured.Unstructured, c fuzz.Continue) { + obj := make(map[string]interface{}) + obj["apiVersion"] = generateValidAPIVersionString(c) + obj["kind"] = generateNonEmptyString(c) + for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- { + obj[c.RandString()] = generateRandomTypeValue(maxUnstructuredDepth, c) + } + u.Object = obj + }, + func(ul *unstructured.UnstructuredList, c fuzz.Continue) { + obj := make(map[string]interface{}) + obj["apiVersion"] = generateValidAPIVersionString(c) + obj["kind"] = generateNonEmptyString(c) + for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- { + obj[c.RandString()] = generateRandomTypeValue(maxUnstructuredDepth, c) + } + for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- { + var item = unstructured.Unstructured{} + c.Fuzz(&item) + ul.Items = append(ul.Items, item) + } + ul.Object = obj + }, + } +} + +func generateNonEmptyString(c fuzz.Continue) string { + temp := c.RandString() + for len(temp) == 0 { + temp = c.RandString() + } + return temp +} + +// generateNonEmptyNoSlashString generates a non-empty string without any slashes +func generateNonEmptyNoSlashString(c fuzz.Continue) string { + temp := strings.ReplaceAll(generateNonEmptyString(c), "/", "") + for len(temp) == 0 { + temp = strings.ReplaceAll(generateNonEmptyString(c), "/", "") + } + return temp +} + +// generateValidAPIVersionString generates valid apiVersion string with formats: +// / or +func generateValidAPIVersionString(c fuzz.Continue) string { + if c.RandBool() { + return generateNonEmptyNoSlashString(c) + "/" + generateNonEmptyNoSlashString(c) + } else { + return generateNonEmptyNoSlashString(c) + } +} + +// generateRandomTypeValue generates fuzzed valid JSON data types: +// 1. numbers (float64, int64) +// 2. string (utf-8 encodings) +// 3. boolean +// 4. array ([]interface{}) +// 5. object (map[string]interface{}) +// 6. null +// Decoding into unstructured can only produce a nil interface{} value or the +// concrete types map[string]interface{}, []interface{}, int64, float64, string, and bool +// If a value of other types is put into an unstructured, it will roundtrip +// to one of the above list of supported types. For example, if Time type is used, +// it will be encoded into a RFC 3339 format string such as "2001-02-03T12:34:56Z" +// and when decoding into Unstructured, there is no information to indicate +// that this string was originally produced by encoding a metav1.Time. +// All external-versioned builtin types are exercised through RoundtripToUnstructured +// in apitesting package. Types like metav1.Time are implicitly being exercised +// because they appear as fields in those types. +func generateRandomTypeValue(depth int, c fuzz.Continue) interface{} { + t := c.Rand.Intn(120) + // If the max depth for unstructured is reached, only add non-recursive types + // which is 20+ in range + if depth == 0 { + t = 20 + c.Rand.Intn(120-20) + } + + switch { + case t < 10: + item := make([]interface{}, c.Intn(maxUnstructuredFanOut)) + for k := range item { + item[k] = generateRandomTypeValue(depth-1, c) + } + return item + case t < 20: + item := map[string]interface{}{} + for j := c.Intn(maxUnstructuredFanOut); j >= 0; j-- { + item[c.RandString()] = generateRandomTypeValue(depth-1, c) + } + return item + case t < 40: + // Only valid UTF-8 encodings + var item string + c.Fuzz(&item) + return item + case t < 60: + var item int64 + c.Fuzz(&item) + return item + case t < 80: + var item bool + c.Fuzz(&item) + return item + case t < 100: + return c.Rand.NormFloat64() + case t < 120: + return nil + default: + panic("invalid case") + } +} + +func unstructuredEqual(t *testing.T, a, b runtime.Unstructured) bool { + return anyEqual(t, a.UnstructuredContent(), b.UnstructuredContent()) +} + +// numberEqual asserts equality of two numbers which one is int64 and one is float64 +// In JSON, a non-decimal float64 is converted to int64 automatically in case the +// float64 fits into int64 range. Otherwise, the non-decimal float64 remains a float. +// As a result, this func does an int64 to float64 conversion using math/big package +// to ensure the conversion is lossless before comparison. +func numberEqual(a int64, b float64) bool { + // Ensure roundtrip int64 to float64 conversion is lossless + f, accuracy := big.NewInt(a).Float64() + if accuracy == big.Exact { + // Distinction between int64 and float64 is not preserved during JSON roundtrip for all numbers. + return f == b + } + return false +} + +func anyEqual(t *testing.T, a, b interface{}) bool { + switch b.(type) { + case nil, bool, string, int64, float64, []interface{}, map[string]interface{}: + default: + t.Fatalf("unexpected value %v of type %T", b, b) + } + + switch ac := a.(type) { + case nil, bool, string: + return ac == b + case int64: + if bc, ok := b.(float64); ok { + return numberEqual(ac, bc) + } + return ac == b + case float64: + if bc, ok := b.(int64); ok { + return numberEqual(bc, ac) + } + return ac == b + case []interface{}: + bc, ok := b.([]interface{}) + if !ok { + return false + } + if len(ac) != len(bc) { + return false + } + for i, aa := range ac { + if !anyEqual(t, aa, bc[i]) { + return false + } + } + return true + case map[string]interface{}: + bc, ok := b.(map[string]interface{}) + if !ok { + return false + } + if len(ac) != len(bc) { + return false + } + for k, aa := range ac { + bb, ok := bc[k] + if !ok { + return false + } + if !anyEqual(t, aa, bb) { + return false + } + } + return true + default: + t.Fatalf("unexpected value %v of type %T", a, a) + } + return true +}