Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/codegen/decode/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,29 +494,45 @@ func (x *{{.Name}}) decodeFromMap(m map[string]any) {
if arr, ok := m["{{.JSONKey}}"].([]any); ok {
x.{{.FieldName}} = make([]*{{.ElemType}}, 0, len(arr))
for _, item := range arr {
{{- if eq .ElemType "Schema"}}
if elem := decodeSchemaValue(item); elem != nil {
x.{{.FieldName}} = append(x.{{.FieldName}}, elem)
}
{{- else}}
if sub, ok := item.(map[string]any); ok {
elem := new({{.ElemType}})
elem.decodeFromMap(sub)
x.{{.FieldName}} = append(x.{{.FieldName}}, elem)
}
{{- end}}
}
}
{{- else if eq .Strategy "oas_ptr"}}
{{- if eq .ElemType "Schema"}}
x.{{.FieldName}} = decodeSchemaValue(m["{{.JSONKey}}"])
{{- else}}
if sub, ok := m["{{.JSONKey}}"].(map[string]any); ok {
x.{{.FieldName}} = new({{.ElemType}})
x.{{.FieldName}}.decodeFromMap(sub)
}
{{- end}}
{{- else if eq .Strategy "string_or_object"}}
x.{{.FieldName}} = decode{{.ElemType}}(m["{{.JSONKey}}"])
{{- else if eq .Strategy "oas_map"}}
if sub, ok := m["{{.JSONKey}}"].(map[string]any); ok {
x.{{.FieldName}} = make(map[string]*{{.ElemType}}, len(sub))
for k, v := range sub {
{{- if eq .ElemType "Schema"}}
if elem := decodeSchemaValue(v); elem != nil {
x.{{.FieldName}}[k] = elem
}
{{- else}}
if vm, ok := v.(map[string]any); ok {
elem := new({{.ElemType}})
elem.decodeFromMap(vm)
x.{{.FieldName}}[k] = elem
}
{{- end}}
}
}
{{- else if eq .Strategy "string_map"}}
Expand Down
4 changes: 4 additions & 0 deletions internal/codegen/deepcopy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ var typeConfigs = []TypeConfig{
{Name: "MinContains", Type: "*int", CopyMethod: "prim_pointer"},
{Name: "MaxProperties", Type: "*int", CopyMethod: "prim_pointer"},
{Name: "MinProperties", Type: "*int", CopyMethod: "prim_pointer"},
// BoolForm is not a spec field, but it is still a pointer: without
// an entry here `*out = *in` would alias the pointee between the
// original and the copy.
{Name: "BoolForm", Type: "*bool", CopyMethod: "prim_pointer"},
// Struct pointer fields
{Name: "Discriminator", Type: "*Discriminator", CopyMethod: "pointer"},
{Name: "XML", Type: "*XML", CopyMethod: "pointer"},
Expand Down
7 changes: 7 additions & 0 deletions internal/driftguard/marshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ var marshalExclusions = map[string]map[string]string{
// 2.0 bare string, which is why this case is checked before decoding.
"StringForm": "selects the OAS 2.0 bare-string form",
},
"Schema": {
// Same shape as StringForm, one level up: setting it re-spells the whole
// Schema as a bare boolean, so the output is the scalar `true` or `false`
// and there is no object left to decode. The value it carries is not lost —
// it *is* the output — so this exclusion covers the key, not the meaning.
"BoolForm": "re-spells the schema as a bare boolean",
},
}

// marshalSubjects pairs each type carrying a hand-built MarshalJSON with a fresh
Expand Down
103 changes: 103 additions & 0 deletions internal/schemautil/bool_schema_hash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package schemautil

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/erraggy/oastools/parser"
)

// A boolean schema has two representations in the parser model. In a
// schema-or-bool field such as Items it stays a raw bool, because the promotion
// step passes bools through untouched; in a *Schema-typed field it arrives as a
// Schema with BoolForm set. They mean the same thing, so they must hash the
// same — otherwise deduplication sorts equivalent schemas into different
// buckets and never compares them.
func TestHashBoolSchemaRepresentationsAgree(t *testing.T) {
tests := []struct {
name string
raw *parser.Schema
wrap *parser.Schema
}{
{
name: "items true",
raw: &parser.Schema{Type: "array", Items: true},
wrap: &parser.Schema{Type: "array", Items: parser.NewBoolSchema(true)},
},
{
name: "items false",
raw: &parser.Schema{Type: "array", Items: false},
wrap: &parser.Schema{Type: "array", Items: parser.NewBoolSchema(false)},
},
{
name: "additionalProperties true",
raw: &parser.Schema{Type: "object", AdditionalProperties: true},
wrap: &parser.Schema{Type: "object", AdditionalProperties: parser.NewBoolSchema(true)},
},
{
name: "additionalProperties false",
raw: &parser.Schema{Type: "object", AdditionalProperties: false},
wrap: &parser.Schema{Type: "object", AdditionalProperties: parser.NewBoolSchema(false)},
},
{
name: "additionalItems true",
raw: &parser.Schema{Type: "array", AdditionalItems: true},
wrap: &parser.Schema{Type: "array", AdditionalItems: parser.NewBoolSchema(true)},
},
{
name: "additionalItems false",
raw: &parser.Schema{Type: "array", AdditionalItems: false},
wrap: &parser.Schema{Type: "array", AdditionalItems: parser.NewBoolSchema(false)},
},
{
name: "unevaluatedProperties true",
raw: &parser.Schema{Type: "object", UnevaluatedProperties: true},
wrap: &parser.Schema{Type: "object", UnevaluatedProperties: parser.NewBoolSchema(true)},
},
{
name: "unevaluatedItems false",
raw: &parser.Schema{Type: "array", UnevaluatedItems: false},
wrap: &parser.Schema{Type: "array", UnevaluatedItems: parser.NewBoolSchema(false)},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, NewSchemaHasher().Hash(tt.raw), NewSchemaHasher().Hash(tt.wrap),
"the raw bool and BoolForm representations must hash alike")
})
}
}

// The two boolean values are opposite schemas, so they must not collide — in
// either representation, and at the top level as well as nested.
func TestHashBoolSchemaValuesDiffer(t *testing.T) {
tests := []struct {
name string
a, b *parser.Schema
}{
{
name: "top-level true and false",
a: parser.NewBoolSchema(true),
b: parser.NewBoolSchema(false),
},
{
name: "raw items true and false",
a: &parser.Schema{Type: "array", Items: true},
b: &parser.Schema{Type: "array", Items: false},
},
{
name: "a boolean schema and an empty object schema",
a: parser.NewBoolSchema(true),
b: &parser.Schema{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.NotEqual(t, NewSchemaHasher().Hash(tt.a), NewSchemaHasher().Hash(tt.b),
"distinct schemas must not share a deduplication bucket")
})
}
}
86 changes: 55 additions & 31 deletions internal/schemautil/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ func (h *SchemaHasher) hashSchema(hasher hash.Hash64, schema *parser.Schema) {
h.visited[ptr] = true
defer func() { h.visited[ptr] = false }()

// The bare-boolean form has no keywords, so it hashes on its value alone.
// `true` and `false` are opposite schemas and must not share a bucket with
// each other or with an object schema, or deduplication groups them and the
// deep comparison never gets to reject the merge.
if b, ok := schema.IsBool(); ok {
h.writeBoolSchema(hasher, b)
return
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Hash $ref if present. JSON Schema 2020-12 allows keywords alongside $ref, so
// this records the reference and keeps going: returning here made
// {$ref: X, default: 1} and {$ref: X, default: 2} hash alike.
Expand All @@ -80,32 +89,7 @@ func (h *SchemaHasher) hashSchema(hasher hash.Hash64, schema *parser.Schema) {
h.writeString(hasher, "pattern:")
h.writeString(hasher, schema.Pattern)

// Enum (order matters). Length-framed: unframed, ["ab"] and ["a", "b"] both
// hash as "enum:ab".
if len(schema.Enum) > 0 {
h.writeString(hasher, "enum:")
for _, v := range schema.Enum {
h.writeLabeled(hasher, "v", fmt.Sprintf("%v", v))
}
}

// Const
if schema.Const != nil {
h.writeString(hasher, "const:")
h.writeString(hasher, fmt.Sprintf("%v", schema.Const))
}

// Required (sort for order-independent comparison). Length-framed for the same
// reason as Enum.
if len(schema.Required) > 0 {
h.writeString(hasher, "required:")
sorted := make([]string, len(schema.Required))
copy(sorted, schema.Required)
sort.Strings(sorted)
for _, r := range sorted {
h.writeLabeled(hasher, "r", r)
}
}
h.hashEnumConstRequired(hasher, schema)

// Properties (sorted by key for deterministic hashing)
if len(schema.Properties) > 0 {
Expand Down Expand Up @@ -364,14 +348,22 @@ func (h *SchemaHasher) hashSchemaOrBool(hasher hash.Hash64, v any) {
case *parser.Schema:
h.hashSchema(hasher, val)
case bool:
if val {
h.writeString(hasher, "true")
} else {
h.writeString(hasher, "false")
}
// Same encoding hashSchema uses for Schema.BoolForm. A boolean schema
// has two representations — a raw bool here, a *Schema with BoolForm
// set in a *Schema-typed position — and they mean the same thing, so
// they must hash the same. Writing a bare "true" here put `items: true`
// and `items: NewBoolSchema(true)` in different buckets.
h.writeBoolSchema(hasher, val)
}
}

// writeBoolSchema writes the bare-boolean schema form. Shared by hashSchema and
// hashSchemaOrBool so the two representations cannot drift apart.
func (h *SchemaHasher) writeBoolSchema(hasher hash.Hash64, v bool) {
h.writeString(hasher, "boolschema:")
h.writeString(hasher, strconv.FormatBool(v))
}

// hashIdentity hashes the JSON Schema identity and dialect keywords. They decide
// which schema a $ref or $dynamicRef resolves to and which vocabulary validates
// it, so two schemas differing here are not interchangeable however alike their
Expand Down Expand Up @@ -413,6 +405,38 @@ func (h *SchemaHasher) hashIdentity(hasher hash.Hash64, schema *parser.Schema) {
// default is an annotation in JSON Schema terms, but two schemas that default
// differently generate different code and fill payloads differently, so
// consolidating them is not safe. collectionFormat decides a wire format outright.
// hashEnumConstRequired hashes the three value-set keywords. Extracted from
// hashSchema purely to keep that function under the complexity limit; the write
// order is unchanged, so hashes computed before and after the extraction match.
func (h *SchemaHasher) hashEnumConstRequired(hasher hash.Hash64, schema *parser.Schema) {
// Enum (order matters). Length-framed: unframed, ["ab"] and ["a", "b"] both
// hash as "enum:ab".
if len(schema.Enum) > 0 {
h.writeString(hasher, "enum:")
for _, v := range schema.Enum {
h.writeLabeled(hasher, "v", fmt.Sprintf("%v", v))
}
}

// Const
if schema.Const != nil {
h.writeString(hasher, "const:")
h.writeString(hasher, fmt.Sprintf("%v", schema.Const))
}

// Required (sort for order-independent comparison). Length-framed for the same
// reason as Enum.
if len(schema.Required) > 0 {
h.writeString(hasher, "required:")
sorted := make([]string, len(schema.Required))
copy(sorted, schema.Required)
sort.Strings(sorted)
for _, r := range sorted {
h.writeLabeled(hasher, "r", r)
}
}
}

func (h *SchemaHasher) hashValueSemantics(hasher hash.Hash64, schema *parser.Schema) {
if schema.Default != nil {
h.writeLabeled(hasher, "default", fmt.Sprintf("%v", schema.Default))
Expand Down
Loading