diff --git a/pkg/api/testing/unstructured_test.go b/pkg/api/testing/unstructured_test.go index 9995d9c3b1c..03539b048f4 100644 --- a/pkg/api/testing/unstructured_test.go +++ b/pkg/api/testing/unstructured_test.go @@ -144,7 +144,7 @@ func TestRoundtripToUnstructured(t *testing.T) { } } - roundtrip.RoundtripToUnstructured(t, legacyscheme.Scheme, FuzzerFuncs, skipped) + roundtrip.RoundtripToUnstructured(t, legacyscheme.Scheme, FuzzerFuncs, skipped, nil) } func TestRoundTripWithEmptyCreationTimestamp(t *testing.T) { diff --git a/staging/src/k8s.io/apiextensions-apiserver/go.mod b/staging/src/k8s.io/apiextensions-apiserver/go.mod index 0a82f226c2a..c1a1826ac32 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/go.mod +++ b/staging/src/k8s.io/apiextensions-apiserver/go.mod @@ -8,6 +8,7 @@ godebug default=go1.23 require ( github.com/emicklei/go-restful/v3 v3.11.0 + github.com/fxamacker/cbor/v2 v2.7.0 github.com/gogo/protobuf v1.3.2 github.com/google/cel-go v0.21.0 github.com/google/gnostic-models v0.6.8 @@ -53,7 +54,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal.go index 321bec385c5..6ade24a82fe 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal.go @@ -20,12 +20,42 @@ import ( "bytes" "errors" + cbor "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct" "k8s.io/apimachinery/pkg/util/json" ) var jsTrue = []byte("true") var jsFalse = []byte("false") +// The CBOR parsing related constants and functions below are not exported so they can be +// easily removed at a future date when the CBOR library provides equivalent functionality. + +type cborMajorType int + +const ( + // https://www.rfc-editor.org/rfc/rfc8949.html#section-3.1 + cborUnsignedInteger cborMajorType = 0 + cborNegativeInteger cborMajorType = 1 + cborByteString cborMajorType = 2 + cborTextString cborMajorType = 3 + cborArray cborMajorType = 4 + cborMap cborMajorType = 5 + cborTag cborMajorType = 6 + cborOther cborMajorType = 7 +) + +const ( + // from https://www.rfc-editor.org/rfc/rfc8949.html#name-jump-table-for-initial-byte. + // additionally, see https://www.rfc-editor.org/rfc/rfc8949.html#section-3.3-5. + cborFalseValue = 0xf4 + cborTrueValue = 0xf5 + cborNullValue = 0xf6 +) + +func cborType(b byte) cborMajorType { + return cborMajorType(b >> 5) +} + func (s JSONSchemaPropsOrBool) MarshalJSON() ([]byte, error) { if s.Schema != nil { return json.Marshal(s.Schema) @@ -59,6 +89,39 @@ func (s *JSONSchemaPropsOrBool) UnmarshalJSON(data []byte) error { return nil } +func (s JSONSchemaPropsOrBool) MarshalCBOR() ([]byte, error) { + if s.Schema != nil { + return cbor.Marshal(s.Schema) + } + return cbor.Marshal(s.Allows) +} + +func (s *JSONSchemaPropsOrBool) UnmarshalCBOR(data []byte) error { + switch { + case len(data) == 0: + // ideally we would avoid modifying *s here, but we are matching the behavior of UnmarshalJSON + *s = JSONSchemaPropsOrBool{} + return nil + case cborType(data[0]) == cborMap: + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrBool{Allows: true, Schema: &p} + return nil + case data[0] == cborTrueValue: + *s = JSONSchemaPropsOrBool{Allows: true} + return nil + case data[0] == cborFalseValue: + *s = JSONSchemaPropsOrBool{Allows: false} + return nil + default: + // ideally, this case would not also capture a null input value, + // but we are matching the behavior of the UnmarshalJSON + return errors.New("boolean or JSON schema expected") + } +} + func (s JSONSchemaPropsOrStringArray) MarshalJSON() ([]byte, error) { if len(s.Property) > 0 { return json.Marshal(s.Property) @@ -91,6 +154,40 @@ func (s *JSONSchemaPropsOrStringArray) UnmarshalJSON(data []byte) error { return nil } +func (s JSONSchemaPropsOrStringArray) MarshalCBOR() ([]byte, error) { + if len(s.Property) > 0 { + return cbor.Marshal(s.Property) + } + if s.Schema != nil { + return cbor.Marshal(s.Schema) + } + return cbor.Marshal(nil) +} + +func (s *JSONSchemaPropsOrStringArray) UnmarshalCBOR(data []byte) error { + if len(data) > 0 && cborType(data[0]) == cborArray { + var a []string + if err := cbor.Unmarshal(data, &a); err != nil { + return err + } + *s = JSONSchemaPropsOrStringArray{Property: a} + return nil + } + if len(data) > 0 && cborType(data[0]) == cborMap { + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrStringArray{Schema: &p} + return nil + } + // At this point we either have: empty data, a null value, or an + // unexpected type. In order to match the behavior of the existing + // UnmarshalJSON, no error is returned and *s is overwritten here. + *s = JSONSchemaPropsOrStringArray{} + return nil +} + func (s JSONSchemaPropsOrArray) MarshalJSON() ([]byte, error) { if len(s.JSONSchemas) > 0 { return json.Marshal(s.JSONSchemas) @@ -120,6 +217,37 @@ func (s *JSONSchemaPropsOrArray) UnmarshalJSON(data []byte) error { return nil } +func (s JSONSchemaPropsOrArray) MarshalCBOR() ([]byte, error) { + if len(s.JSONSchemas) > 0 { + return cbor.Marshal(s.JSONSchemas) + } + return cbor.Marshal(s.Schema) +} + +func (s *JSONSchemaPropsOrArray) UnmarshalCBOR(data []byte) error { + if len(data) > 0 && cborType(data[0]) == cborMap { + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrArray{Schema: &p} + return nil + } + if len(data) > 0 && cborType(data[0]) == cborArray { + var a []JSONSchemaProps + if err := cbor.Unmarshal(data, &a); err != nil { + return err + } + *s = JSONSchemaPropsOrArray{JSONSchemas: a} + return nil + } + // At this point we either have: empty data, a null value, or an + // unexpected type. In order to match the behavior of the existing + // UnmarshalJSON, no error is returned and *s is overwritten here. + *s = JSONSchemaPropsOrArray{} + return nil +} + func (s JSON) MarshalJSON() ([]byte, error) { if len(s.Raw) > 0 { return s.Raw, nil @@ -134,3 +262,34 @@ func (s *JSON) UnmarshalJSON(data []byte) error { } return nil } + +func (s JSON) MarshalCBOR() ([]byte, error) { + // Note that non-semantic whitespace is lost during the transcoding performed here. + // We do not forsee this to be a problem given the current known uses of this type. + // Other limitations that arise when roundtripping JSON via dynamic clients also apply + // here, for example: insignificant whitespace handling, number handling, and map key ordering. + if len(s.Raw) == 0 { + return []byte{cborNullValue}, nil + } + var u any + if err := json.Unmarshal(s.Raw, &u); err != nil { + return nil, err + } + return cbor.Marshal(u) +} + +func (s *JSON) UnmarshalCBOR(data []byte) error { + if len(data) == 0 || data[0] == cborNullValue { + return nil + } + var u any + if err := cbor.Unmarshal(data, &u); err != nil { + return err + } + raw, err := json.Marshal(u) + if err != nil { + return err + } + s.Raw = raw + return nil +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal_test.go index 4ddab379c5d..b1415c73ebd 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/marshal_test.go @@ -18,133 +18,522 @@ package v1 import ( "encoding/json" - "reflect" + "math" "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/google/go-cmp/cmp" ) -type JSONSchemaPropsOrBoolHolder struct { - JSPoB JSONSchemaPropsOrBool `json:"val1"` - JSPoBOmitEmpty *JSONSchemaPropsOrBool `json:"val2,omitempty"` +type marshalTestable interface { + json.Marshaler + cbor.Marshaler } -func TestJSONSchemaPropsOrBoolUnmarshalJSON(t *testing.T) { - cases := []struct { - input string - result JSONSchemaPropsOrBoolHolder - }{ - {`{}`, JSONSchemaPropsOrBoolHolder{}}, - - {`{"val1": {}}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}}}, - {`{"val1": {"type":"string"}}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val1": false}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{}}}, - {`{"val1": true}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true}}}, - - {`{"val2": {}}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}}}, - {`{"val2": {"type":"string"}}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val2": false}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{}}}, - {`{"val2": true}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true}}}, - } - - for _, c := range cases { - var result JSONSchemaPropsOrBoolHolder - if err := json.Unmarshal([]byte(c.input), &result); err != nil { - t.Errorf("Failed to unmarshal input '%v': %v", c.input, err) - } - if !reflect.DeepEqual(result, c.result) { - t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result) - } - } +type marshalTestCase struct { + name string + input marshalTestable + wantJSONError bool + wantCBORError bool + wantJSON []byte + wantCBOR []byte } -func TestStringArrayOrStringMarshalJSON(t *testing.T) { - cases := []struct { - input JSONSchemaPropsOrBoolHolder - result string - }{ - {JSONSchemaPropsOrBoolHolder{}, `{"val1":false}`}, +type unmarshalTestable interface { + json.Unmarshaler + cbor.Unmarshaler +} - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{}}}, `{"val1":{}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":{"type":"string"}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{}}, `{"val1":false}`}, - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true}}, `{"val1":true}`}, +type unmarshalTestCase struct { + name string + inputJSON []byte + inputCBOR []byte + wantJSONError bool + wantCBORError bool + wantDecoded unmarshalTestable +} - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{}}}, `{"val1":false,"val2":{}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":false,"val2":{"type":"string"}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{}}, `{"val1":false,"val2":false}`}, - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true}}, `{"val1":false,"val2":true}`}, - } +type roundTripTestable interface { + marshalTestable + unmarshalTestable +} - for _, c := range cases { - result, err := json.Marshal(&c.input) +type roundTripTestCase struct { + name string + input roundTripTestable + wantJSON []byte + wantCBOR []byte + wantDecoded roundTripTestable +} + +func TestJSONSchemaPropsOrBool(t *testing.T) { + nan := math.NaN() + + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: &JSONSchemaPropsOrBool{ + Schema: &JSONSchemaProps{Maximum: &nan}, + }, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "zero value", + input: &JSONSchemaPropsOrBool{}, + wantDecoded: &JSONSchemaPropsOrBool{}, + wantJSON: []byte(`false`), + wantCBOR: []byte{cborFalseValue}, + }, + { + name: "bool false", + input: &JSONSchemaPropsOrBool{Allows: false}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: false}, + wantJSON: []byte(`false`), + wantCBOR: []byte{cborFalseValue}, + }, + { + name: "bool true", + input: &JSONSchemaPropsOrBool{Allows: true}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true}, + wantJSON: []byte(`true`), + wantCBOR: []byte{cborTrueValue}, + }, + { + name: "with props", + input: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + }) + } + }) + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "legacy behavior", + inputJSON: []byte(`{}`), + inputCBOR: []byte{0xA0}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}, + }, + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantJSONError: true, + wantCBORError: true, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrBool{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + }) + } + }) +} + +func TestJSONSchemaPropsOrArray(t *testing.T) { + nan := math.NaN() + + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Maximum: &nan}}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "empty props", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}, + wantDecoded: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}, + wantJSON: []byte(`{}`), + wantCBOR: []byte{0xA0}, + }, + { + name: "props", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + { + name: "array with empty props", + input: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}, + wantDecoded: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}, + wantJSON: []byte(`[{}]`), + wantCBOR: []byte{0x81, 0xA0}, + }, + { + name: "array with empty props and props", + input: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}, + wantDecoded: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}, + wantJSON: []byte(`[{},{"type":"string"}]`), + wantCBOR: []byte{0x82, 0xA0, 0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + }) + } + }) + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + }) + } + }) +} + +func TestJSONSchemaPropsOrStringArray(t *testing.T) { + nan := math.NaN() + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Maximum: &nan}}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "empty props", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{}}, + wantJSON: []byte(`{}`), + wantCBOR: []byte{0xA0}, + }, + { + name: "props", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + + { + name: "empty array", + input: &JSONSchemaPropsOrStringArray{Property: []string{}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "array value", + input: &JSONSchemaPropsOrStringArray{Property: []string{"string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: []string{"string"}}, + wantJSON: []byte(`["string"]`), + wantCBOR: []byte{0x81, 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + { + name: "both props and array", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "props"}, Property: []string{"string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: nil, Property: []string{"string"}}, + wantJSON: []byte(`["string"]`), + wantCBOR: []byte{0x81, 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + }) + } + }) + + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "empty array", + inputJSON: []byte(`[]`), + inputCBOR: []byte{0x80}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: []string{}}, + }, + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + }) + } + }) + +} + +func TestJSON(t *testing.T) { + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "nil raw", + input: &JSON{Raw: nil}, + wantDecoded: &JSON{Raw: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "zero len raw", + input: &JSON{Raw: []byte{}}, + wantDecoded: &JSON{Raw: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "empty", + input: &JSON{}, + wantDecoded: &JSON{}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "string", + input: &JSON{Raw: []byte(`"string"`)}, + wantDecoded: &JSON{Raw: []byte(`"string"`)}, + wantJSON: []byte(`"string"`), + wantCBOR: []byte{0x46, 0x73, 0x74, 0x72, 0x69, 0x6E, 0x67}, + }, + { + name: "number", + input: &JSON{Raw: []byte(`42.01`)}, + wantDecoded: &JSON{Raw: []byte(`42.01`)}, + wantJSON: []byte(`42.01`), + wantCBOR: []byte{0xFB, 0x40, 0x45, 0x01, 0x47, 0xAE, 0x14, 0x7A, 0xE1}, + }, + { + name: "bool", + input: &JSON{Raw: []byte(`true`)}, + wantDecoded: &JSON{Raw: []byte(`true`)}, + wantJSON: []byte(`true`), + wantCBOR: []byte{0xF5}, + }, + { + name: "array", + input: &JSON{Raw: []byte(`[1,2,3]`)}, + wantDecoded: &JSON{Raw: []byte(`[1,2,3]`)}, + wantJSON: []byte(`[1,2,3]`), + wantCBOR: []byte{0x83, 1, 2, 3}, + }, + { + name: "map", + input: &JSON{Raw: []byte(`{"foo":"bar"}`)}, + wantDecoded: &JSON{Raw: []byte(`{"foo":"bar"}`)}, + wantJSON: []byte(`{"foo":"bar"}`), + wantCBOR: []byte{0xA1, 0x43, 'f', 'o', 'o', 0x43, 'b', 'a', 'r'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSON{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSON{})) + }) + } + }) +} + +func marshalJSONTest(input marshalTestable, wantErr bool, expected []byte) func(t *testing.T) { + return func(t *testing.T) { + actual, err := input.MarshalJSON() + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } if err != nil { - t.Errorf("Unexpected error marshaling input '%v': %v", c.input, err) + return } - if string(result) != c.result { - t.Errorf("Failed to marshal input '%v': expected: %q, got %q", c.input, c.result, string(result)) + if diff := cmp.Diff(string(expected), string(actual)); len(diff) > 0 { + t.Fatal(diff) } } } -type JSONSchemaPropsOrArrayHolder struct { - JSPoA JSONSchemaPropsOrArray `json:"val1"` - JSPoAOmitEmpty *JSONSchemaPropsOrArray `json:"val2,omitempty"` -} - -func TestJSONSchemaPropsOrArrayUnmarshalJSON(t *testing.T) { - cases := []struct { - input string - result JSONSchemaPropsOrArrayHolder - }{ - {`{}`, JSONSchemaPropsOrArrayHolder{}}, - - {`{"val1": {}}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}}, - {`{"val1": {"type":"string"}}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val1": [{}]}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}}, - {`{"val1": [{},{"type":"string"}]}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}}, - - {`{"val2": {}}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}}, - {`{"val2": {"type":"string"}}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val2": [{}]}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}}, - {`{"val2": [{},{"type":"string"}]}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}}, - } - - for _, c := range cases { - var result JSONSchemaPropsOrArrayHolder - if err := json.Unmarshal([]byte(c.input), &result); err != nil { - t.Errorf("Failed to unmarshal input '%v': %v", c.input, err) +func marshalCBORTest(input marshalTestable, wantErr bool, expected []byte) func(t *testing.T) { + return func(t *testing.T) { + actual, err := input.MarshalCBOR() + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) } - if !reflect.DeepEqual(result, c.result) { - t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result) - } - } -} - -func TestJSONSchemaPropsOrArrayMarshalJSON(t *testing.T) { - cases := []struct { - input JSONSchemaPropsOrArrayHolder - result string - }{ - {JSONSchemaPropsOrArrayHolder{}, `{"val1":null}`}, - - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}, `{"val1":{}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":{"type":"string"}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}, `{"val1":[{}]}`}, - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}, `{"val1":[{},{"type":"string"}]}`}, - - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{}}, `{"val1":null,"val2":null}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}, `{"val1":null,"val2":{}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":null,"val2":{"type":"string"}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}, `{"val1":null,"val2":[{}]}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}, `{"val1":null,"val2":[{},{"type":"string"}]}`}, - } - - for i, c := range cases { - result, err := json.Marshal(&c.input) if err != nil { - t.Errorf("%d: Unexpected error marshaling input '%v': %v", i, c.input, err) + return } - if string(result) != c.result { - t.Errorf("%d: Failed to marshal input '%v': expected: %q, got %q", i, c.input, c.result, string(result)) + if diff := cmp.Diff(expected, actual); len(diff) > 0 { + t.Fatal(diff) + } + } +} + +func unmarshalJSONTest(input []byte, wantErr bool, expectedDecoded unmarshalTestable, actualDecoded unmarshalTestable) func(t *testing.T) { + return func(t *testing.T) { + err := actualDecoded.UnmarshalJSON(input) + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func unmarshalCBORTest(input []byte, wantErr bool, expectedDecoded unmarshalTestable, actualDecoded unmarshalTestable) func(t *testing.T) { + return func(t *testing.T) { + err := actualDecoded.UnmarshalCBOR(input) + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) != 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func roundTripJSONTest(input roundTripTestable, expectedEncoded []byte, expectedDecoded roundTripTestable, actualDecoded roundTripTestable) func(t *testing.T) { + return func(t *testing.T) { + actualEncoded, err := input.MarshalJSON() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(expectedEncoded), string(actualEncoded)); len(diff) > 0 { + t.Error("unexpected encoded value") + t.Fatal(diff) + } + err = actualDecoded.UnmarshalJSON(actualEncoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func roundTripCBORTest(input roundTripTestable, expectedEncoded []byte, expectedDecoded roundTripTestable, actualDecoded roundTripTestable) func(t *testing.T) { + return func(t *testing.T) { + actualEncoded, err := input.MarshalCBOR() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedEncoded, actualEncoded); len(diff) > 0 { + t.Error("unexpected encoded value") + t.Fatal(diff) + } + err = actualDecoded.UnmarshalCBOR(actualEncoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) } } } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal.go index 43b90387872..5e6e8253295 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal.go @@ -20,12 +20,40 @@ import ( "bytes" "errors" + cbor "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct" "k8s.io/apimachinery/pkg/util/json" ) var jsTrue = []byte("true") var jsFalse = []byte("false") +// The CBOR parsing related constants and functions below are not exported so they can be +// easily removed at a future date when the CBOR library provides equivalent functionality. + +type cborMajorType int + +const ( + // https://www.rfc-editor.org/rfc/rfc8949.html#section-3.1 + cborUnsignedInteger cborMajorType = 0 + cborNegativeInteger cborMajorType = 1 + cborByteString cborMajorType = 2 + cborTextString cborMajorType = 3 + cborArray cborMajorType = 4 + cborMap cborMajorType = 5 + cborTag cborMajorType = 6 + cborOther cborMajorType = 7 +) + +const ( + cborFalseValue = 0xf4 + cborTrueValue = 0xf5 + cborNullValue = 0xf6 +) + +func cborType(b byte) cborMajorType { + return cborMajorType(b >> 5) +} + func (s JSONSchemaPropsOrBool) MarshalJSON() ([]byte, error) { if s.Schema != nil { return json.Marshal(s.Schema) @@ -59,6 +87,39 @@ func (s *JSONSchemaPropsOrBool) UnmarshalJSON(data []byte) error { return nil } +func (s JSONSchemaPropsOrBool) MarshalCBOR() ([]byte, error) { + if s.Schema != nil { + return cbor.Marshal(s.Schema) + } + return cbor.Marshal(s.Allows) +} + +func (s *JSONSchemaPropsOrBool) UnmarshalCBOR(data []byte) error { + switch { + case len(data) == 0: + // ideally we would avoid modifying *s here, but we are matching the behavior of UnmarshalJSON + *s = JSONSchemaPropsOrBool{} + return nil + case cborType(data[0]) == cborMap: + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrBool{Allows: true, Schema: &p} + return nil + case data[0] == cborTrueValue: + *s = JSONSchemaPropsOrBool{Allows: true} + return nil + case data[0] == cborFalseValue: + *s = JSONSchemaPropsOrBool{Allows: false} + return nil + default: + // ideally, this case would not also capture a null input value, + // but we are matching the behavior of the UnmarshalJSON + return errors.New("boolean or JSON schema expected") + } +} + func (s JSONSchemaPropsOrStringArray) MarshalJSON() ([]byte, error) { if len(s.Property) > 0 { return json.Marshal(s.Property) @@ -91,6 +152,40 @@ func (s *JSONSchemaPropsOrStringArray) UnmarshalJSON(data []byte) error { return nil } +func (s JSONSchemaPropsOrStringArray) MarshalCBOR() ([]byte, error) { + if len(s.Property) > 0 { + return cbor.Marshal(s.Property) + } + if s.Schema != nil { + return cbor.Marshal(s.Schema) + } + return cbor.Marshal(nil) +} + +func (s *JSONSchemaPropsOrStringArray) UnmarshalCBOR(data []byte) error { + if len(data) > 0 && cborType(data[0]) == cborArray { + var a []string + if err := cbor.Unmarshal(data, &a); err != nil { + return err + } + *s = JSONSchemaPropsOrStringArray{Property: a} + return nil + } + if len(data) > 0 && cborType(data[0]) == cborMap { + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrStringArray{Schema: &p} + return nil + } + // At this point we either have: empty data, a null value, or an + // unexpected type. In order to match the behavior of the existing + // UnmarshalJSON, no error is returned and *s is overwritten here. + *s = JSONSchemaPropsOrStringArray{} + return nil +} + func (s JSONSchemaPropsOrArray) MarshalJSON() ([]byte, error) { if len(s.JSONSchemas) > 0 { return json.Marshal(s.JSONSchemas) @@ -120,6 +215,37 @@ func (s *JSONSchemaPropsOrArray) UnmarshalJSON(data []byte) error { return nil } +func (s JSONSchemaPropsOrArray) MarshalCBOR() ([]byte, error) { + if len(s.JSONSchemas) > 0 { + return cbor.Marshal(s.JSONSchemas) + } + return cbor.Marshal(s.Schema) +} + +func (s *JSONSchemaPropsOrArray) UnmarshalCBOR(data []byte) error { + if len(data) > 0 && cborType(data[0]) == cborMap { + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrArray{Schema: &p} + return nil + } + if len(data) > 0 && cborType(data[0]) == cborArray { + var a []JSONSchemaProps + if err := cbor.Unmarshal(data, &a); err != nil { + return err + } + *s = JSONSchemaPropsOrArray{JSONSchemas: a} + return nil + } + // At this point we either have: empty data, a null value, or an + // unexpected type. In order to match the behavior of the existing + // UnmarshalJSON, no error is returned and *s is overwritten here. + *s = JSONSchemaPropsOrArray{} + return nil +} + func (s JSON) MarshalJSON() ([]byte, error) { if len(s.Raw) > 0 { return s.Raw, nil @@ -134,3 +260,34 @@ func (s *JSON) UnmarshalJSON(data []byte) error { } return nil } + +func (s JSON) MarshalCBOR() ([]byte, error) { + // Note that non-semantic whitespace is lost during the transcoding performed here. + // We do not forsee this to be a problem given the current known uses of this type. + // Other limitations that arise when roundtripping JSON via dynamic clients also apply + // here, for example: insignificant whitespace handling, number handling, and map key ordering. + if len(s.Raw) == 0 { + return []byte{cborNullValue}, nil + } + var u any + if err := json.Unmarshal(s.Raw, &u); err != nil { + return nil, err + } + return cbor.Marshal(u) +} + +func (s *JSON) UnmarshalCBOR(data []byte) error { + if len(data) == 0 || data[0] == cborNullValue { + return nil + } + var u any + if err := cbor.Unmarshal(data, &u); err != nil { + return err + } + raw, err := json.Marshal(u) + if err != nil { + return err + } + s.Raw = raw + return nil +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal_test.go index c45e12f9993..cc19d06cb6e 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/marshal_test.go @@ -18,133 +18,522 @@ package v1beta1 import ( "encoding/json" - "reflect" + "math" "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/google/go-cmp/cmp" ) -type JSONSchemaPropsOrBoolHolder struct { - JSPoB JSONSchemaPropsOrBool `json:"val1"` - JSPoBOmitEmpty *JSONSchemaPropsOrBool `json:"val2,omitempty"` +type marshalTestable interface { + json.Marshaler + cbor.Marshaler } -func TestJSONSchemaPropsOrBoolUnmarshalJSON(t *testing.T) { - cases := []struct { - input string - result JSONSchemaPropsOrBoolHolder - }{ - {`{}`, JSONSchemaPropsOrBoolHolder{}}, - - {`{"val1": {}}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}}}, - {`{"val1": {"type":"string"}}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val1": false}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{}}}, - {`{"val1": true}`, JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true}}}, - - {`{"val2": {}}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}}}, - {`{"val2": {"type":"string"}}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val2": false}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{}}}, - {`{"val2": true}`, JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true}}}, - } - - for _, c := range cases { - var result JSONSchemaPropsOrBoolHolder - if err := json.Unmarshal([]byte(c.input), &result); err != nil { - t.Errorf("Failed to unmarshal input '%v': %v", c.input, err) - } - if !reflect.DeepEqual(result, c.result) { - t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result) - } - } +type marshalTestCase struct { + name string + input marshalTestable + wantJSONError bool + wantCBORError bool + wantJSON []byte + wantCBOR []byte } -func TestStringArrayOrStringMarshalJSON(t *testing.T) { - cases := []struct { - input JSONSchemaPropsOrBoolHolder - result string - }{ - {JSONSchemaPropsOrBoolHolder{}, `{"val1":false}`}, +type unmarshalTestable interface { + json.Unmarshaler + cbor.Unmarshaler +} - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{}}}, `{"val1":{}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":{"type":"string"}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{}}, `{"val1":false}`}, - {JSONSchemaPropsOrBoolHolder{JSPoB: JSONSchemaPropsOrBool{Allows: true}}, `{"val1":true}`}, +type unmarshalTestCase struct { + name string + inputJSON []byte + inputCBOR []byte + wantJSONError bool + wantCBORError bool + wantDecoded unmarshalTestable +} - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{}}}, `{"val1":false,"val2":{}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":false,"val2":{"type":"string"}}`}, - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{}}, `{"val1":false,"val2":false}`}, - {JSONSchemaPropsOrBoolHolder{JSPoBOmitEmpty: &JSONSchemaPropsOrBool{Allows: true}}, `{"val1":false,"val2":true}`}, - } +type roundTripTestable interface { + marshalTestable + unmarshalTestable +} - for _, c := range cases { - result, err := json.Marshal(&c.input) +type roundTripTestCase struct { + name string + input roundTripTestable + wantJSON []byte + wantCBOR []byte + wantDecoded roundTripTestable +} + +func TestJSONSchemaPropsOrBool(t *testing.T) { + nan := math.NaN() + + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: &JSONSchemaPropsOrBool{ + Schema: &JSONSchemaProps{Maximum: &nan}, + }, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "zero value", + input: &JSONSchemaPropsOrBool{}, + wantDecoded: &JSONSchemaPropsOrBool{}, + wantJSON: []byte(`false`), + wantCBOR: []byte{cborFalseValue}, + }, + { + name: "bool false", + input: &JSONSchemaPropsOrBool{Allows: false}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: false}, + wantJSON: []byte(`false`), + wantCBOR: []byte{cborFalseValue}, + }, + { + name: "bool true", + input: &JSONSchemaPropsOrBool{Allows: true}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true}, + wantJSON: []byte(`true`), + wantCBOR: []byte{cborTrueValue}, + }, + { + name: "with props", + input: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + }) + } + }) + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "legacy behavior", + inputJSON: []byte(`{}`), + inputCBOR: []byte{0xA0}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}, + }, + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantJSONError: true, + wantCBORError: true, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrBool{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + }) + } + }) +} + +func TestJSONSchemaPropsOrArray(t *testing.T) { + nan := math.NaN() + + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Maximum: &nan}}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "empty props", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}, + wantDecoded: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}, + wantJSON: []byte(`{}`), + wantCBOR: []byte{0xA0}, + }, + { + name: "props", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + { + name: "array with empty props", + input: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}, + wantDecoded: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}, + wantJSON: []byte(`[{}]`), + wantCBOR: []byte{0x81, 0xA0}, + }, + { + name: "array with empty props and props", + input: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}, + wantDecoded: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}, + wantJSON: []byte(`[{},{"type":"string"}]`), + wantCBOR: []byte{0x82, 0xA0, 0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + }) + } + }) + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + }) + } + }) +} + +func TestJSONSchemaPropsOrStringArray(t *testing.T) { + nan := math.NaN() + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Maximum: &nan}}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "empty props", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{}}, + wantJSON: []byte(`{}`), + wantCBOR: []byte{0xA0}, + }, + { + name: "props", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + + { + name: "empty array", + input: &JSONSchemaPropsOrStringArray{Property: []string{}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "array value", + input: &JSONSchemaPropsOrStringArray{Property: []string{"string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: []string{"string"}}, + wantJSON: []byte(`["string"]`), + wantCBOR: []byte{0x81, 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + { + name: "both props and array", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "props"}, Property: []string{"string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: nil, Property: []string{"string"}}, + wantJSON: []byte(`["string"]`), + wantCBOR: []byte{0x81, 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + }) + } + }) + + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "empty array", + inputJSON: []byte(`[]`), + inputCBOR: []byte{0x80}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: []string{}}, + }, + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + }) + } + }) + +} + +func TestJSON(t *testing.T) { + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "nil raw", + input: &JSON{Raw: nil}, + wantDecoded: &JSON{Raw: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "zero len raw", + input: &JSON{Raw: []byte{}}, + wantDecoded: &JSON{Raw: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "empty", + input: &JSON{}, + wantDecoded: &JSON{}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "string", + input: &JSON{Raw: []byte(`"string"`)}, + wantDecoded: &JSON{Raw: []byte(`"string"`)}, + wantJSON: []byte(`"string"`), + wantCBOR: []byte{0x46, 0x73, 0x74, 0x72, 0x69, 0x6E, 0x67}, + }, + { + name: "number", + input: &JSON{Raw: []byte(`42.01`)}, + wantDecoded: &JSON{Raw: []byte(`42.01`)}, + wantJSON: []byte(`42.01`), + wantCBOR: []byte{0xFB, 0x40, 0x45, 0x01, 0x47, 0xAE, 0x14, 0x7A, 0xE1}, + }, + { + name: "bool", + input: &JSON{Raw: []byte(`true`)}, + wantDecoded: &JSON{Raw: []byte(`true`)}, + wantJSON: []byte(`true`), + wantCBOR: []byte{0xF5}, + }, + { + name: "array", + input: &JSON{Raw: []byte(`[1,2,3]`)}, + wantDecoded: &JSON{Raw: []byte(`[1,2,3]`)}, + wantJSON: []byte(`[1,2,3]`), + wantCBOR: []byte{0x83, 1, 2, 3}, + }, + { + name: "map", + input: &JSON{Raw: []byte(`{"foo":"bar"}`)}, + wantDecoded: &JSON{Raw: []byte(`{"foo":"bar"}`)}, + wantJSON: []byte(`{"foo":"bar"}`), + wantCBOR: []byte{0xA1, 0x43, 'f', 'o', 'o', 0x43, 'b', 'a', 'r'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSON{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSON{})) + }) + } + }) +} + +func marshalJSONTest(input marshalTestable, wantErr bool, expected []byte) func(t *testing.T) { + return func(t *testing.T) { + actual, err := input.MarshalJSON() + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } if err != nil { - t.Errorf("Unexpected error marshaling input '%v': %v", c.input, err) + return } - if string(result) != c.result { - t.Errorf("Failed to marshal input '%v': expected: %q, got %q", c.input, c.result, string(result)) + if diff := cmp.Diff(string(expected), string(actual)); len(diff) > 0 { + t.Fatal(diff) } } } -type JSONSchemaPropsOrArrayHolder struct { - JSPoA JSONSchemaPropsOrArray `json:"val1"` - JSPoAOmitEmpty *JSONSchemaPropsOrArray `json:"val2,omitempty"` -} - -func TestJSONSchemaPropsOrArrayUnmarshalJSON(t *testing.T) { - cases := []struct { - input string - result JSONSchemaPropsOrArrayHolder - }{ - {`{}`, JSONSchemaPropsOrArrayHolder{}}, - - {`{"val1": {}}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}}, - {`{"val1": {"type":"string"}}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val1": [{}]}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}}, - {`{"val1": [{},{"type":"string"}]}`, JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}}, - - {`{"val2": {}}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}}, - {`{"val2": {"type":"string"}}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}}, - {`{"val2": [{}]}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}}, - {`{"val2": [{},{"type":"string"}]}`, JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}}, - } - - for _, c := range cases { - var result JSONSchemaPropsOrArrayHolder - if err := json.Unmarshal([]byte(c.input), &result); err != nil { - t.Errorf("Failed to unmarshal input '%v': %v", c.input, err) +func marshalCBORTest(input marshalTestable, wantErr bool, expected []byte) func(t *testing.T) { + return func(t *testing.T) { + actual, err := input.MarshalCBOR() + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) } - if !reflect.DeepEqual(result, c.result) { - t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result) - } - } -} - -func TestJSONSchemaPropsOrArrayMarshalJSON(t *testing.T) { - cases := []struct { - input JSONSchemaPropsOrArrayHolder - result string - }{ - {JSONSchemaPropsOrArrayHolder{}, `{"val1":null}`}, - - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}, `{"val1":{}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":{"type":"string"}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}, `{"val1":[{}]}`}, - {JSONSchemaPropsOrArrayHolder{JSPoA: JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}, `{"val1":[{},{"type":"string"}]}`}, - - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{}}, `{"val1":null,"val2":null}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}}, `{"val1":null,"val2":{}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}}, `{"val1":null,"val2":{"type":"string"}}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}}, `{"val1":null,"val2":[{}]}`}, - {JSONSchemaPropsOrArrayHolder{JSPoAOmitEmpty: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}}, `{"val1":null,"val2":[{},{"type":"string"}]}`}, - } - - for i, c := range cases { - result, err := json.Marshal(&c.input) if err != nil { - t.Errorf("%d: Unexpected error marshaling input '%v': %v", i, c.input, err) + return } - if string(result) != c.result { - t.Errorf("%d: Failed to marshal input '%v': expected: %q, got %q", i, c.input, c.result, string(result)) + if diff := cmp.Diff(expected, actual); len(diff) > 0 { + t.Fatal(diff) + } + } +} + +func unmarshalJSONTest(input []byte, wantErr bool, expectedDecoded unmarshalTestable, actualDecoded unmarshalTestable) func(t *testing.T) { + return func(t *testing.T) { + err := actualDecoded.UnmarshalJSON(input) + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func unmarshalCBORTest(input []byte, wantErr bool, expectedDecoded unmarshalTestable, actualDecoded unmarshalTestable) func(t *testing.T) { + return func(t *testing.T) { + err := actualDecoded.UnmarshalCBOR(input) + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) != 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func roundTripJSONTest(input roundTripTestable, expectedEncoded []byte, expectedDecoded roundTripTestable, actualDecoded roundTripTestable) func(t *testing.T) { + return func(t *testing.T) { + actualEncoded, err := input.MarshalJSON() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(expectedEncoded), string(actualEncoded)); len(diff) > 0 { + t.Error("unexpected encoded value") + t.Fatal(diff) + } + err = actualDecoded.UnmarshalJSON(actualEncoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func roundTripCBORTest(input roundTripTestable, expectedEncoded []byte, expectedDecoded roundTripTestable, actualDecoded roundTripTestable) func(t *testing.T) { + return func(t *testing.T) { + actualEncoded, err := input.MarshalCBOR() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedEncoded, actualEncoded); len(diff) > 0 { + t.Error("unexpected encoded value") + t.Fatal(diff) + } + err = actualDecoded.UnmarshalCBOR(actualEncoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) } } } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/roundtrip_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/roundtrip_test.go index d01e6dae0f0..b0ab41f9717 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/roundtrip_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/roundtrip_test.go @@ -21,19 +21,24 @@ import ( "strconv" "testing" + fuzz "github.com/google/gofuzz" "github.com/stretchr/testify/require" - apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - apiextensionv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/install" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" "k8s.io/apimachinery/pkg/api/apitesting/roundtrip" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - _ "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/util/sets" ) var groups = []runtime.SchemeBuilder{ - apiextensionv1.SchemeBuilder, - apiextensionv1beta1.SchemeBuilder, + apiextensionsv1.SchemeBuilder, + apiextensionsv1beta1.SchemeBuilder, } func TestCompatibility(t *testing.T) { @@ -46,11 +51,11 @@ func TestCompatibility(t *testing.T) { // Fill unstructured JSON field types opts.FillFuncs = map[reflect.Type]roundtrip.FillFunc{ - reflect.TypeOf(&apiextensionv1.JSON{}): func(s string, i int, obj interface{}) { - obj.(*apiextensionv1.JSON).Raw = []byte(strconv.Quote(s + "Value")) + reflect.TypeOf(&apiextensionsv1.JSON{}): func(s string, i int, obj interface{}) { + obj.(*apiextensionsv1.JSON).Raw = []byte(strconv.Quote(s + "Value")) }, - reflect.TypeOf(&apiextensionv1beta1.JSON{}): func(s string, i int, obj interface{}) { - obj.(*apiextensionv1beta1.JSON).Raw = []byte(strconv.Quote(s + "Value")) + reflect.TypeOf(&apiextensionsv1beta1.JSON{}): func(s string, i int, obj interface{}) { + obj.(*apiextensionsv1beta1.JSON).Raw = []byte(strconv.Quote(s + "Value")) }, } @@ -59,7 +64,7 @@ func TestCompatibility(t *testing.T) { // limit to types in apiextensions.k8s.io filteredKinds := []schema.GroupVersionKind{} for _, gvk := range opts.Kinds { - if gvk.Group == apiextensionv1.SchemeGroupVersion.Group { + if gvk.Group == apiextensionsv1.SchemeGroupVersion.Group { filteredKinds = append(filteredKinds, gvk) } } @@ -67,3 +72,59 @@ func TestCompatibility(t *testing.T) { opts.Run(t) } + +func TestRoundtripToUnstructured(t *testing.T) { + scheme := runtime.NewScheme() + install.Install(scheme) + roundtrip.RoundtripToUnstructured(t, scheme, + fuzzer.MergeFuzzerFuncs( + apiextensionsfuzzer.Funcs, + func(_ serializer.CodecFactory) []any { + return []any{ + func(obj *apiextensionsv1.ConversionReview, c fuzz.Continue) { + c.FuzzNoCustom(obj) + if obj.Request != nil { + for i := range obj.Request.Objects { + fuzzer.NormalizeJSONRawExtension(&obj.Request.Objects[i]) + } + } + if obj.Response != nil { + for i := range obj.Response.ConvertedObjects { + fuzzer.NormalizeJSONRawExtension(&obj.Response.ConvertedObjects[i]) + } + } + }, + func(obj *apiextensionsv1beta1.ConversionReview, c fuzz.Continue) { + c.FuzzNoCustom(obj) + if obj.Request != nil { + for i := range obj.Request.Objects { + fuzzer.NormalizeJSONRawExtension(&obj.Request.Objects[i]) + } + } + if obj.Response != nil { + for i := range obj.Response.ConvertedObjects { + fuzzer.NormalizeJSONRawExtension(&obj.Response.ConvertedObjects[i]) + } + } + }, + } + }, + ), + // skip types that are never serialized in the body of a request/response. + sets.New( + apiextensionsv1.SchemeGroupVersion.WithKind("CreateOptions"), + apiextensionsv1.SchemeGroupVersion.WithKind("PatchOptions"), + apiextensionsv1.SchemeGroupVersion.WithKind("UpdateOptions"), + apiextensionsv1beta1.SchemeGroupVersion.WithKind("CreateOptions"), + apiextensionsv1beta1.SchemeGroupVersion.WithKind("PatchOptions"), + apiextensionsv1beta1.SchemeGroupVersion.WithKind("UpdateOptions"), + ), + // the following types do not have an "internal" go type, so we fuzz the + // versioned type directly instead of converting to/from the "internal" version + // during the round-tripping. + sets.New( + apiextensionsv1.SchemeGroupVersion.WithKind("ConversionReview"), + apiextensionsv1beta1.SchemeGroupVersion.WithKind("ConversionReview"), + ), + ) +} diff --git a/staging/src/k8s.io/apimachinery/pkg/api/apitesting/fuzzer/fuzzer.go b/staging/src/k8s.io/apimachinery/pkg/api/apitesting/fuzzer/fuzzer.go index f528e9f92de..f8539fa998a 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/apitesting/fuzzer/fuzzer.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/apitesting/fuzzer/fuzzer.go @@ -17,11 +17,15 @@ limitations under the License. package fuzzer import ( + "encoding/json" + "fmt" "math/rand" "github.com/google/gofuzz" + "k8s.io/apimachinery/pkg/runtime" runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer" + kjson "k8s.io/apimachinery/pkg/util/json" ) // FuzzerFuncs returns a list of func(*SomeType, c fuzz.Continue) functions. @@ -50,3 +54,20 @@ func MergeFuzzerFuncs(funcs ...FuzzerFuncs) FuzzerFuncs { return result }) } + +func NormalizeJSONRawExtension(ext *runtime.RawExtension) { + if json.Valid(ext.Raw) { + // RawExtension->JSON encodes struct fields in field index order while map[string]interface{}->JSON encodes + // struct fields (i.e. keys in the map) lexicographically. We have to sort the fields here to ensure the + // JSON in the (RawExtension->)JSON->map[string]interface{}->JSON round trip results in identical JSON. + var u any + err := kjson.Unmarshal(ext.Raw, &u) + if err != nil { + panic(fmt.Sprintf("Failed to encode object: %v", err)) + } + ext.Raw, err = kjson.Marshal(&u) + if err != nil { + panic(fmt.Sprintf("Failed to encode object: %v", err)) + } + } +} diff --git a/staging/src/k8s.io/apimachinery/pkg/api/apitesting/roundtrip/unstructured.go b/staging/src/k8s.io/apimachinery/pkg/api/apitesting/roundtrip/unstructured.go index 35be03ac9a4..de10580f5df 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/apitesting/roundtrip/unstructured.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/apitesting/roundtrip/unstructured.go @@ -27,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" apiequality "k8s.io/apimachinery/pkg/api/equality" + metafuzzer "k8s.io/apimachinery/pkg/apis/meta/fuzzer" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -43,7 +44,11 @@ import ( // from native to unstructured and back using both the JSON and CBOR serializers. The intermediate // unstructured objects produced by both encodings must be identical and be themselves // roundtrippable to JSON and CBOR. -func RoundtripToUnstructured(t *testing.T, scheme *runtime.Scheme, funcs fuzzer.FuzzerFuncs, skipped sets.Set[schema.GroupVersionKind]) { +// +// Values for all external types in the scheme are generated by fuzzing the a value of the +// corresponding internal type and converting it, except for types whose registered GVK appears in +// the "nointernal" set, which are fuzzed directly. +func RoundtripToUnstructured(t *testing.T, scheme *runtime.Scheme, funcs fuzzer.FuzzerFuncs, skipped sets.Set[schema.GroupVersionKind], nointernal sets.Set[schema.GroupVersionKind]) { codecs := serializer.NewCodecFactory(scheme) seed := int64(time.Now().Nanosecond()) @@ -63,6 +68,7 @@ func RoundtripToUnstructured(t *testing.T, scheme *runtime.Scheme, funcs fuzzer. if globalNonRoundTrippableTypes.Has(gvk.Kind) { continue } + if gvk.Version == runtime.APIVersionInternal { continue } @@ -74,27 +80,29 @@ func RoundtripToUnstructured(t *testing.T, scheme *runtime.Scheme, funcs fuzzer. t.Run(subtestName, func(t *testing.T) { if skipped.Has(gvk) { - t.Skip() + t.SkipNow() } - fuzzer := fuzzer.FuzzerFor(funcs, rand.NewSource(seed), codecs) - - for i := 0; i < 50; i++ { - // We do fuzzing on the internal version of the object, and only then - // convert to the external version. This is because custom fuzzing - // function are only supported for internal objects. - internalObj, err := scheme.New(schema.GroupVersion{Group: gvk.Group, Version: runtime.APIVersionInternal}.WithKind(gvk.Kind)) - if err != nil { - t.Fatalf("couldn't create internal object %v: %v", gvk.Kind, err) - } - fuzzer.Fuzz(internalObj) + fuzzer := fuzzer.FuzzerFor(fuzzer.MergeFuzzerFuncs(metafuzzer.Funcs, funcs), rand.NewSource(seed), codecs) + for i := 0; i < *FuzzIters; i++ { item, err := scheme.New(gvk) if err != nil { t.Fatalf("couldn't create external object %v: %v", gvk.Kind, err) } - if err := scheme.Convert(internalObj, item, nil); err != nil { - t.Fatalf("conversion for %v failed: %v", gvk.Kind, err) + + if nointernal.Has(gvk) { + fuzzer.Fuzz(item) + } else { + internalObj, err := scheme.New(gvk.GroupKind().WithVersion(runtime.APIVersionInternal)) + if err != nil { + t.Fatalf("couldn't create internal object %v: %v", gvk.Kind, err) + } + fuzzer.Fuzz(internalObj) + + if err := scheme.Convert(internalObj, item, nil); err != nil { + t.Fatalf("conversion for %v failed: %v", gvk.Kind, err) + } } // Decoding into Unstructured requires that apiVersion and kind be diff --git a/staging/src/k8s.io/kube-aggregator/pkg/apis/roundtrip_test.go b/staging/src/k8s.io/kube-aggregator/pkg/apis/roundtrip_test.go new file mode 100644 index 00000000000..3ba5d269644 --- /dev/null +++ b/staging/src/k8s.io/kube-aggregator/pkg/apis/roundtrip_test.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apis + +import ( + "testing" + + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" + "k8s.io/apimachinery/pkg/api/apitesting/roundtrip" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/kube-aggregator/pkg/apis/apiregistration/install" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" + apiregistrationv1beta1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1" +) + +func TestRoundtripToUnstructured(t *testing.T) { + scheme := runtime.NewScheme() + install.Install(scheme) + + roundtrip.RoundtripToUnstructured(t, scheme, fuzzer.MergeFuzzerFuncs(), sets.New( + apiregistrationv1.SchemeGroupVersion.WithKind("CreateOptions"), + apiregistrationv1.SchemeGroupVersion.WithKind("PatchOptions"), + apiregistrationv1.SchemeGroupVersion.WithKind("UpdateOptions"), + apiregistrationv1beta1.SchemeGroupVersion.WithKind("CreateOptions"), + apiregistrationv1beta1.SchemeGroupVersion.WithKind("PatchOptions"), + apiregistrationv1beta1.SchemeGroupVersion.WithKind("UpdateOptions"), + ), nil) +}