diff --git a/internal/test/spec_validation/spec_validation_test.go b/internal/test/spec_validation/spec_validation_test.go new file mode 100644 index 000000000..3f5441639 --- /dev/null +++ b/internal/test/spec_validation/spec_validation_test.go @@ -0,0 +1,52 @@ +// Package spec_validation exercises codegen.ValidateSpec against specs that +// place a hostile value in a single sensitive field. Each testdata spec targets +// exactly one field, because ValidateSpec reports every problem it finds and we +// want each case to assert on a specific, friendly message. A separate spec +// proves that values which merely look suspicious but are legitimate (media-type +// parameters with quotes, multi-line descriptions) are accepted. +package spec_validation + +import ( + "path/filepath" + "testing" + + "github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen" + "github.com/oapi-codegen/oapi-codegen/v2/pkg/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateSpecRejects(t *testing.T) { + // file -> substring expected in the (friendly) error message. + cases := map[string]string{ + "path_newline.yaml": "path", + "parameter_name_quote.yaml": "parameter name", + "property_name_backtick.yaml": "property name", + "content_type_newline.yaml": "content type", + "enum_value_newline.yaml": "enum value", + "discriminator_quote.yaml": "discriminator", + "header_name_quote.yaml": "header", + "extra_tags_backtick.yaml": "x-oapi-codegen-extra-tags", + "x_go_name_invalid.yaml": "x-go-name", + "x_go_type_semicolon.yaml": "x-go-type", + "ref_sibling_x_go_type.yaml": "x-go-type", + "security_scope_quote.yaml": "scope", + } + + for file, want := range cases { + t.Run(file, func(t *testing.T) { + spec, err := util.LoadSwagger(filepath.Join("testdata", file)) + require.NoError(t, err, "test spec should load") + + err = codegen.ValidateSpec(spec) + require.Error(t, err, "ValidateSpec should reject %s", file) + assert.Contains(t, err.Error(), want, "error message should point at the offending field") + }) + } +} + +func TestValidateSpecAcceptsLegitimateSpec(t *testing.T) { + spec, err := util.LoadSwagger(filepath.Join("testdata", "valid.yaml")) + require.NoError(t, err) + require.NoError(t, codegen.ValidateSpec(spec), "a legitimate spec must not be rejected") +} diff --git a/internal/test/spec_validation/testdata/content_type_newline.yaml b/internal/test/spec_validation/testdata/content_type_newline.yaml new file mode 100644 index 000000000..cb582edbd --- /dev/null +++ b/internal/test/spec_validation/testdata/content_type_newline.yaml @@ -0,0 +1,12 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: + /foo: + post: + operationId: postFoo + requestBody: + content: + "application/json\ninjected": + schema: {type: string} + responses: + '200': {description: ok} diff --git a/internal/test/spec_validation/testdata/discriminator_quote.yaml b/internal/test/spec_validation/testdata/discriminator_quote.yaml new file mode 100644 index 000000000..6b79cc397 --- /dev/null +++ b/internal/test/spec_validation/testdata/discriminator_quote.yaml @@ -0,0 +1,14 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + discriminator: + propertyName: 'ev"il' + Cat: + type: object + properties: + name: {type: string} diff --git a/internal/test/spec_validation/testdata/enum_value_newline.yaml b/internal/test/spec_validation/testdata/enum_value_newline.yaml new file mode 100644 index 000000000..ca02eaf0d --- /dev/null +++ b/internal/test/spec_validation/testdata/enum_value_newline.yaml @@ -0,0 +1,9 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Color: + type: string + enum: + - "blue\ninjected" diff --git a/internal/test/spec_validation/testdata/extra_tags_backtick.yaml b/internal/test/spec_validation/testdata/extra_tags_backtick.yaml new file mode 100644 index 000000000..bc8d987f8 --- /dev/null +++ b/internal/test/spec_validation/testdata/extra_tags_backtick.yaml @@ -0,0 +1,12 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Thing: + type: object + properties: + name: + type: string + x-oapi-codegen-extra-tags: + validate: 'ev`il' diff --git a/internal/test/spec_validation/testdata/header_name_quote.yaml b/internal/test/spec_validation/testdata/header_name_quote.yaml new file mode 100644 index 000000000..16457f5ad --- /dev/null +++ b/internal/test/spec_validation/testdata/header_name_quote.yaml @@ -0,0 +1,12 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: + /foo: + get: + operationId: getFoo + responses: + '200': + description: ok + headers: + 'X-Ev"il': + schema: {type: string} diff --git a/internal/test/spec_validation/testdata/parameter_name_quote.yaml b/internal/test/spec_validation/testdata/parameter_name_quote.yaml new file mode 100644 index 000000000..41205d285 --- /dev/null +++ b/internal/test/spec_validation/testdata/parameter_name_quote.yaml @@ -0,0 +1,12 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: + /foo: + get: + operationId: getFoo + parameters: + - name: 'ev"il' + in: query + schema: {type: string} + responses: + '200': {description: ok} diff --git a/internal/test/spec_validation/testdata/path_newline.yaml b/internal/test/spec_validation/testdata/path_newline.yaml new file mode 100644 index 000000000..405de63c7 --- /dev/null +++ b/internal/test/spec_validation/testdata/path_newline.yaml @@ -0,0 +1,8 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: + "/foo\ninjected": + get: + operationId: getFoo + responses: + '200': {description: ok} diff --git a/internal/test/spec_validation/testdata/property_name_backtick.yaml b/internal/test/spec_validation/testdata/property_name_backtick.yaml new file mode 100644 index 000000000..6da7436a3 --- /dev/null +++ b/internal/test/spec_validation/testdata/property_name_backtick.yaml @@ -0,0 +1,9 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Thing: + type: object + properties: + 'ev`il': {type: string} diff --git a/internal/test/spec_validation/testdata/ref_sibling_x_go_type.yaml b/internal/test/spec_validation/testdata/ref_sibling_x_go_type.yaml new file mode 100644 index 000000000..afc31b985 --- /dev/null +++ b/internal/test/spec_validation/testdata/ref_sibling_x_go_type.yaml @@ -0,0 +1,18 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Thing: + type: object + properties: + child: + # An x-go-type sibling next to a $ref is honoured by codegen, so the + # ref node's own extensions must be validated even though the target + # (Child) is not traversed here. + $ref: '#/components/schemas/Child' + x-go-type: "int; var pwned = 1" + Child: + type: object + properties: + name: {type: string} diff --git a/internal/test/spec_validation/testdata/security_scope_quote.yaml b/internal/test/spec_validation/testdata/security_scope_quote.yaml new file mode 100644 index 000000000..8b60291f4 --- /dev/null +++ b/internal/test/spec_validation/testdata/security_scope_quote.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: + /foo: + get: + operationId: getFoo + # The scope string here is copied verbatim into a generated []string, so a + # quote in it must be rejected. + security: + - petstore_auth: ['ev"il'] + responses: + '200': {description: ok} +components: + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/auth + scopes: + read: read access diff --git a/internal/test/spec_validation/testdata/valid.yaml b/internal/test/spec_validation/testdata/valid.yaml new file mode 100644 index 000000000..8724b4a0d --- /dev/null +++ b/internal/test/spec_validation/testdata/valid.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: {title: valid, version: "1.0.0"} +paths: + /things: + post: + operationId: createThing + requestBody: + content: + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"': + schema: {$ref: '#/components/schemas/Thing'} + responses: + '200': {description: ok} +components: + schemas: + Thing: + type: object + description: | + A multi-line description + spanning several lines is fine. + properties: + name: {type: string} diff --git a/internal/test/spec_validation/testdata/x_go_name_invalid.yaml b/internal/test/spec_validation/testdata/x_go_name_invalid.yaml new file mode 100644 index 000000000..69f02f65c --- /dev/null +++ b/internal/test/spec_validation/testdata/x_go_name_invalid.yaml @@ -0,0 +1,11 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Thing: + type: object + properties: + name: + type: string + x-go-name: "9lives has spaces" diff --git a/internal/test/spec_validation/testdata/x_go_type_semicolon.yaml b/internal/test/spec_validation/testdata/x_go_type_semicolon.yaml new file mode 100644 index 000000000..2b470f660 --- /dev/null +++ b/internal/test/spec_validation/testdata/x_go_type_semicolon.yaml @@ -0,0 +1,11 @@ +openapi: "3.0.0" +info: {title: t, version: "1.0.0"} +paths: {} +components: + schemas: + Thing: + type: object + properties: + name: + type: string + x-go-type: "int; var pwned = 1" diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 51b009d9d..e87142dcf 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -172,6 +172,14 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { pruneUnusedComponents(spec) } + // Reject spec values that cannot be represented in the generated Go source + // (names, media types, enum values, extension hints containing quotes, + // backticks, or control characters). Run after filtering/pruning so only + // values that will actually be emitted are considered. + if err := ValidateSpec(spec); err != nil { + return "", err + } + // if we are provided an override for the response type suffix update it if opts.OutputOptions.ResponseTypeSuffix != "" { responseTypeSuffix = opts.OutputOptions.ResponseTypeSuffix diff --git a/pkg/codegen/validate_spec.go b/pkg/codegen/validate_spec.go new file mode 100644 index 000000000..d7f31e46c --- /dev/null +++ b/pkg/codegen/validate_spec.go @@ -0,0 +1,410 @@ +package codegen + +import ( + "errors" + "fmt" + "go/token" + "unicode" + + "github.com/getkin/kin-openapi/openapi3" +) + +// ValidateSpec walks a loaded OpenAPI document and rejects values that cannot be +// represented in the generated Go source. +// +// oapi-codegen copies many spec strings verbatim into the code it emits: +// parameter names, property names, media types and enum values become Go string +// literals and struct-tag contents; the `x-go-name`/`x-go-type`/`x-go-type-name` +// extensions become Go identifiers and type expressions. A value containing a +// quote, a backtick or a control character (such as a newline) would produce Go +// that does not compile. Catching that here turns an obscure compiler error on +// generated code into a clear message that points at the offending part of the +// spec. +// +// It is meant to run after any overlay has been applied and after tag/operation +// filtering and component pruning, so only values that will actually be emitted +// are checked. `$ref`s are not followed: every ref resolves to a component that +// is validated at its own definition, and the contents of external documents are +// never copied into the generated output, so they cannot affect it. +func ValidateSpec(spec *openapi3.T) error { + if spec == nil { + return nil + } + v := &specValidator{} + v.walkDocument(spec) + return errors.Join(v.errs...) +} + +type specValidator struct { + errs []error +} + +func (v *specValidator) addf(format string, args ...any) { + v.errs = append(v.errs, fmt.Errorf(format, args...)) +} + +// checkText validates a free-form string that is copied verbatim into the +// generated code (a name, media type, enum value, path, or tag content). Such a +// value ends up inside Go string literals, backtick-delimited struct tags and +// line comments, so it may not contain a double quote, a backtick, or a control +// character (newlines included). `what` describes the location for the message. +func (v *specValidator) checkText(value, what string) { + for _, r := range value { + if r == '"' || r == '`' || unicode.IsControl(r) { + v.addf("%s may not contain %s: %q", what, describeRune(r), value) + return + } + } +} + +// checkNoControl validates a string that is copied into the generated code but +// may legitimately contain quotes — for example a media type, whose parameters +// use quoted-strings (`application/ld+json; profile="…"`). Such values are +// emitted through strconv.Quote at their string-literal sinks, which handles the +// quotes safely, so only control characters (newlines that would break out of a +// line comment) are rejected here. +func (v *specValidator) checkNoControl(value, what string) { + for _, r := range value { + if unicode.IsControl(r) { + v.addf("%s may not contain %s: %q", what, describeRune(r), value) + return + } + } +} + +// checkIdentifier validates a value that becomes a Go identifier (the `x-go-name` +// and `x-go-type-name` extensions). It must be a valid, non-keyword Go +// identifier, which by definition excludes quotes, backticks and control +// characters. +func (v *specValidator) checkIdentifier(value, what string) { + if !token.IsIdentifier(value) { + v.addf("%s must be a valid Go identifier: %q", what, value) + } +} + +// checkGoType validates a value that becomes a Go type expression (the `x-go-type` +// extension). It legitimately contains characters an identifier cannot (`.`, +// `[]`, `*`, `map[...]`, `struct{}`, …), so instead of an identifier check it +// forbids the characters that would let the value escape the type position and +// introduce a new declaration or statement: quotes, backticks, semicolons and +// control characters. +func (v *specValidator) checkGoType(value, what string) { + for _, r := range value { + if r == '"' || r == '`' || r == ';' || unicode.IsControl(r) { + v.addf("%s must be a Go type expression and may not contain %s: %q", what, describeRune(r), value) + return + } + } +} + +// checkTagKey validates the key of an `x-oapi-codegen-extra-tags` entry. Struct +// tag keys are space-separated tokens terminated by ':', so they may not contain +// spaces, colons, quotes, backticks or control characters, and may not be empty. +func (v *specValidator) checkTagKey(key, what string) { + if key == "" { + v.addf("%s may not be empty", what) + return + } + for _, r := range key { + if r == '"' || r == '`' || r == ':' || r == ' ' || unicode.IsControl(r) { + v.addf("%s may not contain %s: %q", what, describeRune(r), key) + return + } + } +} + +// checkSecurity validates the scopes named in security requirements. The +// provider names are emitted only after identifier sanitization, but scopes are +// copied verbatim into a generated `[]string` literal (via toStringArray), so +// they are checked as text. +func (v *specValidator) checkSecurity(reqs openapi3.SecurityRequirements, where string) { + for _, req := range reqs { + for _, provider := range SortedMapKeys(req) { + for _, scope := range req[provider] { + v.checkText(scope, fmt.Sprintf("security scope for %q in %s", provider, where)) + } + } + } +} + +func describeRune(r rune) string { + switch r { + case '"': + return "a double quote" + case '`': + return "a backtick" + case '\n': + return "a newline" + case '\r': + return "a carriage return" + case '\t': + return "a tab" + case ';': + return "a semicolon" + case ':': + return "a colon" + case ' ': + return "a space" + } + if unicode.IsControl(r) { + return fmt.Sprintf("a control character (%U)", r) + } + return fmt.Sprintf("%q", r) +} + +// checkExtensions validates the code-affecting extensions on any spec object. +// Values are parsed with the same helpers code generation uses, and only checked +// when the parse succeeds — a malformed extension is ignored during generation, +// so it cannot inject anything and need not be rejected here. +func (v *specValidator) checkExtensions(ext map[string]any, where string) { + if ext == nil { + return + } + if raw, ok := ext[extGoName]; ok { + if s, err := extParseGoFieldName(raw); err == nil { + v.checkIdentifier(s, "x-go-name in "+where) + } + } + if raw, ok := ext[extGoTypeName]; ok { + if s, err := extTypeName(raw); err == nil { + v.checkIdentifier(s, "x-go-type-name in "+where) + } + } + if raw, ok := ext[extPropGoType]; ok { + if s, err := extString(raw); err == nil { + v.checkGoType(s, "x-go-type in "+where) + } + } + if raw, ok := ext[extPropExtraTags]; ok { + if tags, err := extExtraTags(raw); err == nil { + for _, k := range SortedMapKeys(tags) { + v.checkTagKey(k, "x-oapi-codegen-extra-tags key in "+where) + v.checkText(tags[k], fmt.Sprintf("x-oapi-codegen-extra-tags value for %q in %s", k, where)) + } + } + } +} + +func (v *specValidator) walkDocument(spec *openapi3.T) { + v.checkSecurity(spec.Security, "the root security requirements") + if spec.Paths != nil { + paths := spec.Paths.Map() + for _, path := range SortedMapKeys(paths) { + where := fmt.Sprintf("path %q", path) + v.checkText(path, "OpenAPI path") + v.walkPathItem(paths[path], where) + } + } + for _, name := range SortedMapKeys(spec.Webhooks) { + where := fmt.Sprintf("webhook %q", name) + v.checkText(name, "webhook name") + v.walkPathItem(spec.Webhooks[name], where) + } + if spec.Components != nil { + v.walkComponents(spec.Components) + } +} + +func (v *specValidator) walkComponents(c *openapi3.Components) { + for _, name := range SortedMapKeys(c.Schemas) { + v.walkSchemaRef(c.Schemas[name], fmt.Sprintf("schema %q", name)) + } + for _, name := range SortedMapKeys(c.Parameters) { + v.walkParameterRef(c.Parameters[name], fmt.Sprintf("component parameter %q", name)) + } + for _, name := range SortedMapKeys(c.RequestBodies) { + v.walkRequestBodyRef(c.RequestBodies[name], fmt.Sprintf("component request body %q", name)) + } + for _, name := range SortedMapKeys(c.Responses) { + v.walkResponseRef(c.Responses[name], fmt.Sprintf("component response %q", name)) + } + for _, name := range SortedMapKeys(c.Headers) { + v.walkHeaderRef(c.Headers[name], fmt.Sprintf("component header %q", name)) + } + for _, name := range SortedMapKeys(c.Callbacks) { + v.walkCallbackRef(c.Callbacks[name], fmt.Sprintf("component callback %q", name)) + } +} + +func (v *specValidator) walkPathItem(item *openapi3.PathItem, where string) { + if item == nil { + return + } + for _, p := range item.Parameters { + v.walkParameterRef(p, where) + } + for method, op := range item.Operations() { + v.walkOperation(op, fmt.Sprintf("%s %s", method, where)) + } +} + +func (v *specValidator) walkOperation(op *openapi3.Operation, where string) { + if op == nil { + return + } + v.checkExtensions(op.Extensions, where) + if op.Security != nil { + v.checkSecurity(*op.Security, where) + } + for _, p := range op.Parameters { + v.walkParameterRef(p, where) + } + v.walkRequestBodyRef(op.RequestBody, where) + if op.Responses != nil { + responses := op.Responses.Map() + for _, name := range SortedMapKeys(responses) { + v.walkResponseRef(responses[name], fmt.Sprintf("response %q in %s", name, where)) + } + } + for _, name := range SortedMapKeys(op.Callbacks) { + v.checkText(name, fmt.Sprintf("callback name in %s", where)) + v.walkCallbackRef(op.Callbacks[name], fmt.Sprintf("callback %q in %s", name, where)) + } +} + +func (v *specValidator) walkParameterRef(ref *openapi3.ParameterRef, where string) { + if ref == nil { + return + } + v.checkExtensions(ref.Extensions, where) + if ref.Ref != "" || ref.Value == nil { + return + } + p := ref.Value + loc := fmt.Sprintf("parameter %q in %s", p.Name, where) + v.checkText(p.Name, "parameter name in "+where) + v.checkText(p.Style, "style of "+loc) + v.checkExtensions(p.Extensions, loc) + v.walkContent(p.Content, loc) + v.walkSchemaRef(p.Schema, loc) +} + +func (v *specValidator) walkRequestBodyRef(ref *openapi3.RequestBodyRef, where string) { + if ref == nil { + return + } + v.checkExtensions(ref.Extensions, where) + if ref.Ref != "" || ref.Value == nil { + return + } + loc := "request body in " + where + v.checkExtensions(ref.Value.Extensions, loc) + v.walkContent(ref.Value.Content, loc) +} + +func (v *specValidator) walkResponseRef(ref *openapi3.ResponseRef, where string) { + if ref == nil { + return + } + v.checkExtensions(ref.Extensions, where) + if ref.Ref != "" || ref.Value == nil { + return + } + r := ref.Value + for _, name := range SortedMapKeys(r.Headers) { + v.checkText(name, "response header name in "+where) + v.walkHeaderRef(r.Headers[name], fmt.Sprintf("header %q in %s", name, where)) + } + v.walkContent(r.Content, where) + v.checkExtensions(r.Extensions, where) +} + +func (v *specValidator) walkHeaderRef(ref *openapi3.HeaderRef, where string) { + if ref == nil { + return + } + v.checkExtensions(ref.Extensions, where) + if ref.Ref != "" || ref.Value == nil { + return + } + v.checkExtensions(ref.Value.Extensions, where) + v.walkContent(ref.Value.Content, where) + v.walkSchemaRef(ref.Value.Schema, where) +} + +func (v *specValidator) walkCallbackRef(ref *openapi3.CallbackRef, where string) { + if ref == nil { + return + } + v.checkExtensions(ref.Extensions, where) + if ref.Ref != "" || ref.Value == nil { + return + } + items := ref.Value.Map() + for _, expr := range SortedMapKeys(items) { + v.walkPathItem(items[expr], where) + } +} + +func (v *specValidator) walkContent(content openapi3.Content, where string) { + for _, mediaType := range SortedMapKeys(content) { + v.checkNoControl(mediaType, "content type in "+where) + media := content[mediaType] + if media == nil { + continue + } + loc := fmt.Sprintf("content type %q in %s", mediaType, where) + v.checkExtensions(media.Extensions, loc) + v.walkSchemaRef(media.Schema, loc) + } +} + +func (v *specValidator) walkSchemaRef(ref *openapi3.SchemaRef, where string) { + if ref == nil { + return + } + // Extensions on the ref node itself are honoured by code generation even for + // a $ref — e.g. an `x-go-type` sibling next to a `$ref` overrides the + // referenced type — so they must be checked here. The referenced target is + // validated at its own definition, so we still do not follow the ref, which + // also keeps inline schemas a finite tree with no cycle tracking needed. + v.checkExtensions(ref.Extensions, where) + if ref.Ref != "" || ref.Value == nil { + return + } + v.walkSchema(ref.Value, where) +} + +func (v *specValidator) walkSchema(s *openapi3.Schema, where string) { + if s == nil { + return + } + v.checkExtensions(s.Extensions, where) + v.checkText(s.Format, "format in "+where) + if s.Type != nil { + for _, t := range s.Type.Slice() { + v.checkText(t, "type in "+where) + } + } + // Enum values are emitted through strconv.Quote in the const block, so quotes + // in a value are handled safely; only control characters are rejected. + for _, enumValue := range s.Enum { + if str, ok := enumValue.(string); ok { + v.checkNoControl(str, "enum value in "+where) + } + } + if s.Discriminator != nil { + v.checkText(s.Discriminator.PropertyName, "discriminator propertyName in "+where) + for _, key := range SortedMapKeys(s.Discriminator.Mapping) { + v.checkText(key, "discriminator mapping key in "+where) + } + } + for _, name := range SortedMapKeys(s.Properties) { + v.checkText(name, "property name in "+where) + v.walkSchemaRef(s.Properties[name], fmt.Sprintf("property %q in %s", name, where)) + } + v.walkSchemaRef(s.Items, "items in "+where) + if s.AdditionalProperties.Schema != nil { + v.walkSchemaRef(s.AdditionalProperties.Schema, "additionalProperties in "+where) + } + for i, sub := range s.AllOf { + v.walkSchemaRef(sub, fmt.Sprintf("allOf[%d] in %s", i, where)) + } + for i, sub := range s.AnyOf { + v.walkSchemaRef(sub, fmt.Sprintf("anyOf[%d] in %s", i, where)) + } + for i, sub := range s.OneOf { + v.walkSchemaRef(sub, fmt.Sprintf("oneOf[%d] in %s", i, where)) + } + v.walkSchemaRef(s.Not, "not in "+where) +}