diff --git a/default_validator.go b/default_validator.go index 5902cee..93394d4 100644 --- a/default_validator.go +++ b/default_validator.go @@ -230,9 +230,7 @@ func (d *defaultValidator) validateDefaultValueSchemaAgainstSchema(path pathSegm s := d.SpecValidator if schema.Default != nil { - res.Merge( - newSchemaValidator(schema, s.spec.Spec(), path.child(jsonDefault), s.KnownFormats, d.schemaOptions).Validate(schema.Default), - ) + res.Merge(s.validateValueAgainstSchema(schema, path.child(jsonDefault), schema.Default, d.schemaOptions)) } if schema.Items != nil { if schema.Items.Schema != nil { diff --git a/example_validator.go b/example_validator.go index 0e193d5..a30ca87 100644 --- a/example_validator.go +++ b/example_validator.go @@ -214,10 +214,7 @@ func (ex *exampleValidator) validateExampleInResponse( exampleAt := responsePath(path, method, responseCodeAsStr). child(swaggerExamples). structuralChild(jsonMimeApplicationJSON) - res.MergeAsWarnings( - newSchemaValidator(response.Schema, s.spec.Spec(), - exampleAt, s.KnownFormats, s.schemaOptions).Validate(example), - ) + res.MergeAsWarnings(s.validateValueAgainstSchema(response.Schema, exampleAt, example, s.schemaOptions)) } else { // Proposal for enhancement: validate other media types too res.addWarningsAt(responsePath(path, method, responseCodeAsStr).child(swaggerExamples), examplesMimeNotSupportedMsg(operationID, responseName)) @@ -239,9 +236,7 @@ func (ex *exampleValidator) validateExampleValueSchemaAgainstSchema(path pathSeg res := validatorPools.results.Borrow() if schema.Example != nil { - res.MergeAsWarnings( - newSchemaValidator(schema, s.spec.Spec(), path.child(swaggerExample), s.KnownFormats, ex.schemaOptions).Validate(schema.Example), - ) + res.MergeAsWarnings(s.validateValueAgainstSchema(schema, path.child(swaggerExample), schema.Example, ex.schemaOptions)) } if schema.Items != nil { if schema.Items.Schema != nil { diff --git a/pools_leak_test.go b/pools_leak_test.go index 4243d39..e97d0f1 100644 --- a/pools_leak_test.go +++ b/pools_leak_test.go @@ -79,3 +79,24 @@ func TestPools_NoLeakOnNilSchema(t *testing.T) { require.NoError(t, AgainstSchema(nil, map[string]any{}, strfmt.Default)) require.True(t, pools.AssertNoLeaks(t)) } + +// TestPools_NoDoubleRedeemOnPanic builds a validator that panics inside Validate: the $ref of +// property "x" is expanded when the object validator walks the data. The validators that +// redeem themselves on the way out must not be redeemed again by their parent. +// +// Like the other tests here, it only bites with the "poolsdebug" tag: the pools then panic +// on a double redeem, and that panic replaces the one from the schema. +func TestPools_NoDoubleRedeemOnPanic(t *testing.T) { + resetPools() + pools.ResetTracking() + t.Cleanup(resetPools) + t.Cleanup(pools.ResetTracking) + + var schema spec.Schema + require.NoError(t, json.Unmarshal([]byte(`{"properties":{"x":{"$ref":"#/definitions/missing"}}}`), &schema)) + + require.PanicsWithValue(t, + `Invalid schema provided to SchemaValidator: nil value has no field "missing": JSON pointer error`, + func() { _ = AgainstSchema(&schema, map[string]any{"x": 1}, strfmt.Default) }, + ) +} diff --git a/schema.go b/schema.go index 9b49007..d8fd3ac 100644 --- a/schema.go +++ b/schema.go @@ -94,6 +94,15 @@ func rootPathFromString(root string) pathSegments { } func newSchemaValidator(schema *spec.Schema, rootSchema any, root pathSegments, formats strfmt.Registry, opts *SchemaValidatorOptions) *SchemaValidator { + return newSchemaValidatorWithBudget(schema, rootSchema, root, formats, opts, nil) +} + +// newSchemaValidatorWithBudget builds a schema validator, drawing the validators built for +// allOf, anyOf, oneOf and not from budget. A nil budget starts a new one if needed. +func newSchemaValidatorWithBudget( + schema *spec.Schema, rootSchema any, root pathSegments, formats strfmt.Registry, opts *SchemaValidatorOptions, + budget *compositionBudget, +) *SchemaValidator { if schema == nil { return nil } @@ -131,7 +140,7 @@ func newSchemaValidator(schema *spec.Schema, rootSchema any, root pathSegments, s.validators = [8]valueValidator{ s.typeValidator(), - s.schemaPropsValidator(), + s.schemaPropsValidator(budget), s.stringValidator(), s.formatValidator(), s.numberValidator(), @@ -265,10 +274,12 @@ func (s *SchemaValidator) Validate(data any) *Result { continue } - result.Merge(v.Validate(d)) if s.Options.recycleValidators { - s.validators[idx] = nil // prevents further (unsafe) usage + // prevents further (unsafe) usage. Validate redeems it, even if it panics: + // redeemChildren must not redeem it again + s.validators[idx] = nil } + result.Merge(v.Validate(d)) result.Inc() } result.Inc() @@ -352,11 +363,11 @@ func (s *SchemaValidator) formatValidator() valueValidator { ) } -func (s *SchemaValidator) schemaPropsValidator() valueValidator { +func (s *SchemaValidator) schemaPropsValidator(budget *compositionBudget) valueValidator { sch := s.Schema return newSchemaPropsValidator( s.path, s.in, sch.AllOf, sch.OneOf, sch.AnyOf, sch.Not, sch.Dependencies, s.Root, s.KnownFormats, - s.Options, + s.Options, budget, ) } diff --git a/schema_composition_test.go b/schema_composition_test.go new file mode 100644 index 0000000..a073ffb --- /dev/null +++ b/schema_composition_test.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/go-openapi/loads" + "github.com/go-openapi/spec" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestCompositionBudget(t *testing.T) { + // each of these schemas loops through allOf, anyOf, oneOf or not and their $ref: + // without a budget, building the validator overflows the goroutine stack + // (fatal, not recoverable) or never ends + for _, tc := range []struct { + name string + schema string + }{ + { + name: "allOf cycle", + schema: `{"definitions":{"CycleAllOf":{"allOf":[{"$ref":"#/definitions/CycleAllOf"}]}},"properties":{"x":{"$ref":"#/definitions/CycleAllOf"}}}`, + }, + { + name: "anyOf cycle", + schema: `{"definitions":{"CycleAnyOf":{"anyOf":[{"type":"string"},{"$ref":"#/definitions/CycleAnyOf"}]}},"properties":{"x":{"$ref":"#/definitions/CycleAnyOf"}}}`, + }, + { + name: "oneOf cycle", + schema: `{"definitions":{"CycleOneOf":{"oneOf":[{"$ref":"#/definitions/CycleOneOf"}]}},"properties":{"x":{"$ref":"#/definitions/CycleOneOf"}}}`, + }, + { + name: "not cycle", + schema: `{"definitions":{"CycleNot":{"not":{"$ref":"#/definitions/CycleNot"}}},"properties":{"x":{"$ref":"#/definitions/CycleNot"}}}`, + }, + { + name: "mutual cycle", + schema: `{ + "definitions":{ + "CycleMutualA":{"allOf":[{"$ref":"#/definitions/CycleMutualB"}]}, + "CycleMutualB":{"anyOf":[{"$ref":"#/definitions/CycleMutualA"}]} + }, + "properties":{"x":{"$ref":"#/definitions/CycleMutualA"}} + }`, + }, + { + // 2 branches per level: 2^depth validators without a budget + name: "fan-out cycle", + schema: `{ + "definitions":{"CycleFanOut":{"allOf":[{"$ref":"#/definitions/CycleFanOut"},{"$ref":"#/definitions/CycleFanOut"}]}}, + "properties":{"x":{"$ref":"#/definitions/CycleFanOut"}} + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var schema spec.Schema + require.NoError(t, json.Unmarshal([]byte(tc.schema), &schema)) + + defer func() { + r := recover() + require.NotNil(t, r) + msg, ok := r.(string) + require.TrueT(t, ok) + assert.True(t, strings.HasPrefix(msg, "Invalid schema provided to SchemaValidator: ")) + assert.True(t, strings.Contains(msg, fmt.Sprintf("deeper than %d levels or into more than %d validators", maxCompositionDepth, maxCompositionValidators))) + }() + + _ = AgainstSchema(&schema, map[string]any{"x": 1}, strfmt.Default) + }) + } +} + +func TestCompositionBudgetRecursiveSchema(t *testing.T) { + // a $ref back to an ancestor through properties or items is built as the data is walked: + // the depth of the data bounds it, and it does not draw from the budget + const tree = `{ + "definitions":{ + "BudgetNode":{ + "allOf":[ + {"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}, + {"properties":{"children":{"type":"array","items":{"$ref":"#/definitions/BudgetNode"}}}} + ] + } + }, + "properties":{"root":{"$ref":"#/definitions/BudgetNode"}} + }` + + var schema spec.Schema + require.NoError(t, json.Unmarshal([]byte(tree), &schema)) + + // nest the data deeper than both caps: each level only builds the validators of one Node + const depth = 2 * maxCompositionDepth + node := map[string]any{nameProp: "leaf"} + for range depth { + node = map[string]any{nameProp: "node", "children": []any{node}} + } + + require.NoError(t, AgainstSchema(&schema, map[string]any{"root": node}, strfmt.Default)) + + node["children"] = []any{map[string]any{"children": []any{}}} // missing name + require.Error(t, AgainstSchema(&schema, map[string]any{"root": node}, strfmt.Default)) +} + +func TestCompositionBudgetWideSchema(t *testing.T) { + // many siblings within the budget still build + const width = maxCompositionValidators + branches := make([]spec.Schema, width) + for i := range branches { + branches[i] = *spec.StringProperty() + } + schema := &spec.Schema{SchemaProps: spec.SchemaProps{AllOf: branches}} + + require.NoError(t, AgainstSchema(schema, "a", strfmt.Default)) + require.Error(t, AgainstSchema(schema, 1, strfmt.Default)) + + // one more panics + schema.AllOf = append(schema.AllOf, *spec.StringProperty()) + require.Panics(t, func() { _ = AgainstSchema(schema, "a", strfmt.Default) }) +} + +func TestCompositionBudgetAmplification(t *testing.T) { + // no cycle, and within maxCompositionDepth: A0 refers twice to A1, which refers twice to A2, + // and so on. Expanding it stays within the budget of go-openapi/spec, but it would build + // 2^levels validators. + const levels = 13 + definitions := make(map[string]any, levels+1) + for i := range levels { + ref := map[string]any{jsonRef: fmt.Sprintf("#/definitions/BudgetA%d", i+1)} + definitions[fmt.Sprintf("BudgetA%d", i)] = map[string]any{"allOf": []any{ref, ref}} + } + definitions[fmt.Sprintf("BudgetA%d", levels)] = map[string]any{jsonType: stringType} + + doc, err := json.Marshal(map[string]any{ + swaggerDefinitions: definitions, + "properties": map[string]any{"x": map[string]any{jsonRef: "#/definitions/BudgetA0"}}, + }) + require.NoError(t, err) + + var schema spec.Schema + require.NoError(t, json.Unmarshal(doc, &schema)) + + require.PanicsWithValue(t, + invalidSchemaProvidedMsg(tooManyCompositionValidatorsMsg("x", maxCompositionDepth, maxCompositionValidators)).Error(), + func() { _ = AgainstSchema(&schema, map[string]any{"x": "a"}, strfmt.Default) }, + ) +} + +func TestCompositionBudgetDeepSchema(t *testing.T) { + // nesting allOf within the depth cap still builds + schema := spec.StringProperty() + for range maxCompositionDepth { + schema = &spec.Schema{SchemaProps: spec.SchemaProps{AllOf: []spec.Schema{*schema}}} + } + + require.NoError(t, AgainstSchema(schema, "a", strfmt.Default)) + require.Error(t, AgainstSchema(schema, 1, strfmt.Default)) + + // one level more panics + schema = &spec.Schema{SchemaProps: spec.SchemaProps{AllOf: []spec.Schema{*schema}}} + require.Panics(t, func() { _ = AgainstSchema(schema, "a", strfmt.Default) }) +} + +func TestCompositionBudgetSpec(t *testing.T) { + // Spec stops on the circular ancestry of CycleSpec before it checks defaults and examples. + // ContinueOnErrors goes on: building a validator for the default or the example panics, + // and the spec validator returns it as an error (default) or a warning (example). + const doc = `{ + "swagger":"2.0", + "info":{"title":"t","version":"1"}, + "paths":{}, + "definitions":{ + "CycleSpec":{"default":1,"example":1,"allOf":[{"$ref":"#/definitions/CycleSpec"}]} + } + }` + + analyzed, err := loads.Analyzed(json.RawMessage(doc), "") + require.NoError(t, err) + + validator := NewSpecValidator(analyzed.Schema(), strfmt.Default) + validator.SetContinueOnErrors(true) + + var errs, warnings *Result + require.NotPanics(t, func() { errs, warnings = validator.Validate(analyzed) }) + + const tooDeep = "nests allOf, anyOf, oneOf and not deeper than" + assert.True(t, hasMessageContaining(errs.Errors, "has circular ancestry")) + assert.True(t, hasMessageContaining(errs.Errors, tooDeep)) + assert.True(t, hasMessageContaining(warnings.Errors, tooDeep)) +} + +func hasMessageContaining(errs []error, substr string) bool { + for _, err := range errs { + if strings.Contains(err.Error(), substr) { + return true + } + } + + return false +} diff --git a/schema_messages.go b/schema_messages.go index e0f1801..d2af486 100644 --- a/schema_messages.go +++ b/schema_messages.go @@ -4,6 +4,8 @@ package validate import ( + "strings" + "github.com/go-openapi/errors" ) @@ -34,10 +36,18 @@ const ( // NOTE: punctuation in message. MustValidateAllSchemasError = "%q must validate all the schemas (allOf)%s" + // TooManyCompositionValidatorsError indicates that allOf, anyOf, oneOf and not, followed through + // their $ref, nest too deep or need too many validators. A circular $ref through these + // keywords is the usual cause. + TooManyCompositionValidatorsError = "%q nests allOf, anyOf, oneOf and not deeper than %d levels or into more than %d validators: look for a circular $ref" + // MustNotValidateSchemaError indicates that in a Not construct, the schema constraint specified was verified. MustNotValidateSchemaError = "%q must not validate the schema (not)" ) +// invalidSchemaProvidedPrefix starts the panic message of a schema validator that cannot be built. +var invalidSchemaProvidedPrefix = strings.TrimSuffix(InvalidSchemaProvidedError, "%v") + // Warning messages related to schema validation and returned as results. const () @@ -45,6 +55,10 @@ func invalidSchemaProvidedMsg(err error) errors.Error { return errors.New(InternalErrorCode, InvalidSchemaProvidedError, err) } +func tooManyCompositionValidatorsMsg(path string, maxDepth, maxValidators int) errors.Error { + return errors.New(InternalErrorCode, TooManyCompositionValidatorsError, path, maxDepth, maxValidators) +} + func invalidTypeConversionMsg(path string, err error) errors.Error { return errors.New(errors.CompositeErrorCode, InvalidTypeConversionError, path, err) } diff --git a/schema_props.go b/schema_props.go index d7ecb14..29a761f 100644 --- a/schema_props.go +++ b/schema_props.go @@ -11,6 +11,48 @@ import ( "github.com/go-openapi/strfmt" ) +const ( + // maxCompositionDepth caps the nesting of allOf, anyOf, oneOf and not, followed through + // their $ref, below one schema validator. + maxCompositionDepth = 128 + + // maxCompositionValidators caps the validators built for allOf, anyOf, oneOf and not, followed + // through their $ref, below one schema validator. + maxCompositionValidators = 10_000 +) + +// compositionBudget bounds the validators built for allOf, anyOf, oneOf and not. +// +// These validators are built with their parent, not when Validate walks the data, and each one +// expands its own $ref. go-openapi/spec bounds a single expansion, but a circular $ref stays in +// place and is expanded again one level down: A: {allOf: [{$ref: A}]} would build validators +// until the goroutine stack overflows, and A: {allOf: [{$ref: A}, {$ref: A}]} would build +// 2^depth of them. +// +// Validators built for properties, items and the like are built as Validate walks the data, so +// the depth of the data bounds them. They start their own budget. +type compositionBudget struct { + left int // validators left to build + depth int // current nesting of allOf, anyOf, oneOf and not +} + +// enter draws n validators one level deeper, and panics when either cap is exceeded, +// like other schemas that cannot be expanded. +func (b *compositionBudget) enter(n int, path pathSegments) { + b.left -= n + b.depth++ + + if b.left < 0 || b.depth > maxCompositionDepth { + panic(invalidSchemaProvidedMsg( + tooManyCompositionValidatorsMsg(path.dotted(), maxCompositionDepth, maxCompositionValidators), + ).Error()) + } +} + +func (b *compositionBudget) leave() { + b.depth-- +} + type schemaPropsValidator struct { Path pathSegments In string @@ -30,28 +72,42 @@ type schemaPropsValidator struct { func newSchemaPropsValidator( path pathSegments, in string, allOf, oneOf, anyOf []spec.Schema, not *spec.Schema, deps spec.Dependencies, root any, formats strfmt.Registry, - opts *SchemaValidatorOptions, + opts *SchemaValidatorOptions, budget *compositionBudget, ) *schemaPropsValidator { if opts == nil { opts = new(SchemaValidatorOptions) } + children := len(anyOf) + len(allOf) + len(oneOf) + if not != nil { + children++ + } + + if children > 0 { + if budget == nil { + budget = &compositionBudget{left: maxCompositionValidators} + } + + budget.enter(children, path) + defer budget.leave() + } + anyValidators := make([]*SchemaValidator, 0, len(anyOf)) for i := range anyOf { - anyValidators = append(anyValidators, newSchemaValidator(&anyOf[i], root, path, formats, opts)) + anyValidators = append(anyValidators, newSchemaValidatorWithBudget(&anyOf[i], root, path, formats, opts, budget)) } allValidators := make([]*SchemaValidator, 0, len(allOf)) for i := range allOf { - allValidators = append(allValidators, newSchemaValidator(&allOf[i], root, path, formats, opts)) + allValidators = append(allValidators, newSchemaValidatorWithBudget(&allOf[i], root, path, formats, opts, budget)) } oneValidators := make([]*SchemaValidator, 0, len(oneOf)) for i := range oneOf { - oneValidators = append(oneValidators, newSchemaValidator(&oneOf[i], root, path, formats, opts)) + oneValidators = append(oneValidators, newSchemaValidatorWithBudget(&oneOf[i], root, path, formats, opts, budget)) } var notValidator *SchemaValidator if not != nil { - notValidator = newSchemaValidator(not, root, path, formats, opts) + notValidator = newSchemaValidatorWithBudget(not, root, path, formats, opts, budget) } var s *schemaPropsValidator @@ -141,10 +197,10 @@ func (s *schemaPropsValidator) validateAnyOf(data any, mainResult, keepResultAny var bestFailures *Result for i, anyOfSchema := range s.anyOfValidators { - result := anyOfSchema.Validate(data) if s.Options.recycleValidators { - s.anyOfValidators[i] = nil + s.anyOfValidators[i] = nil // Validate redeems it, even if it panics } + result := anyOfSchema.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us keepResultAnyOf.Merge(result.keepRelevantErrors()) // merges (and redeems) a new instance of Result @@ -186,10 +242,10 @@ func (s *schemaPropsValidator) validateOneOf(data any, mainResult, keepResultOne ) for i, oneOfSchema := range s.oneOfValidators { - result := oneOfSchema.Validate(data) if s.Options.recycleValidators { - s.oneOfValidators[i] = nil + s.oneOfValidators[i] = nil // Validate redeems it, even if it panics } + result := oneOfSchema.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us keepResultOneOf.Merge(result.keepRelevantErrors()) // merges (and redeems) a new instance of Result @@ -242,10 +298,10 @@ func (s *schemaPropsValidator) validateAllOf(data any, mainResult, keepResultAll var validated int for i, allOfSchema := range s.allOfValidators { - result := allOfSchema.Validate(data) if s.Options.recycleValidators { - s.allOfValidators[i] = nil + s.allOfValidators[i] = nil // Validate redeems it, even if it panics } + result := allOfSchema.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us keepResultAllOf.Merge(result.keepRelevantErrors()) if result.IsValid() { @@ -264,10 +320,11 @@ func (s *schemaPropsValidator) validateAllOf(data any, mainResult, keepResultAll } func (s *schemaPropsValidator) validateNot(data any, mainResult *Result) { - result := s.notValidator.Validate(data) + notValidator := s.notValidator if s.Options.recycleValidators { - s.notValidator = nil + s.notValidator = nil // Validate redeems it, even if it panics } + result := notValidator.Validate(data) // We keep inner IMPORTANT! errors no matter what MatchCount tells us if result.IsValid() { mainResult.addErrorsAt(s.Path, mustNotValidatechemaMsg(s.Path.dotted())) diff --git a/schema_props_test.go b/schema_props_test.go index bd8a203..b981354 100644 --- a/schema_props_test.go +++ b/schema_props_test.go @@ -18,7 +18,7 @@ import ( func TestSchemaPropsValidator_EdgeCases(t *testing.T) { t.Run("should validate props against empty validator", func(t *testing.T) { s := newSchemaPropsValidator( - nil, "", nil, nil, nil, nil, nil, nil, strfmt.Default, nil) + nil, "", nil, nil, nil, nil, nil, nil, strfmt.Default, nil, nil) s.setPath(newPathSegments("path")) assert.EqualT(t, "path", s.Path.dotted()) }) @@ -30,7 +30,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { []spec.Schema{ *spec.StringProperty(), *spec.StrFmtProperty("date"), - }, nil, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + }, nil, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate date string", func(t *testing.T) { @@ -69,7 +69,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { []spec.Schema{ *spec.Int64Property(), *spec.StrFmtProperty("date"), - }, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + }, nil, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate date string", func(t *testing.T) { @@ -100,7 +100,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { []spec.Schema{ *spec.StringProperty(), *spec.StrFmtProperty("date"), - }, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + }, nil, nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate date string", func(t *testing.T) { @@ -130,7 +130,7 @@ func TestSchemaPropsValidator_EdgeCases(t *testing.T) { nil, nil, spec.StringProperty(), - nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}) + nil, nil, strfmt.Default, &SchemaValidatorOptions{recycleValidators: true}, nil) } t.Run("should validate number", func(t *testing.T) { diff --git a/spec.go b/spec.go index 1337523..e06688e 100644 --- a/spec.go +++ b/spec.go @@ -624,6 +624,35 @@ func (s *SpecValidator) validateReferencedDefinitions() *Result { return result } +// validateValueAgainstSchema validates a default or an example against a schema declared by the spec. +// +// newSchemaValidator panics on a schema it cannot build: a $ref it cannot expand, or a circular $ref +// through allOf that exceeds maxCompositionDepth or maxCompositionValidators. Here the schema comes +// from the spec under validation, so the panic is returned as an error at the location of the value. +// Other panics are not recovered. +func (s *SpecValidator) validateValueAgainstSchema( + schema *spec.Schema, at pathSegments, value any, opts *SchemaValidatorOptions, +) (res *Result) { + defer func() { + r := recover() + if r == nil { + return + } + + msg, ok := r.(string) + if !ok || !strings.HasPrefix(msg, invalidSchemaProvidedPrefix) { + panic(r) + } + + // the validators and results borrowed before the panic are not redeemed: the garbage + // collector reclaims them + res = validatorPools.results.Borrow() + res.addErrorsAt(at, errors.New(InternalErrorCode, "%s", msg)) + }() + + return newSchemaValidator(schema, s.spec.Spec(), at, s.KnownFormats, opts).Validate(value) +} + func (s *SpecValidator) validateRequiredDefinitions() *Result { // Each property listed in the required array must be defined in the properties of the model res := validatorPools.results.Borrow()