From cddb2b389c4450ffb5874f0a4a2d9f39ec7fc42c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 13:57:21 +0000 Subject: [PATCH] Check declared schema locations for external resources --- docs/roadmap/implementation.md | 5 ++ internal/validate/schema_locations.go | 91 ++++++++++++++++++++++ internal/validate/schema_locations_test.go | 60 ++++++++++++++ internal/validate/standard_validator.go | 9 ++- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 internal/validate/schema_locations.go create mode 100644 internal/validate/schema_locations_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4363a06..16c638f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -350,6 +350,11 @@ anchor references, explicit/embedded IDs, recursive/dynamic references, escaped keys, and embedded-resource dialect distinctions. Keep independent valid/invalid instances; do not manufacture expectations with the traversal under test. +Completion: declared schema locations are compiled using the pinned compiler's +dialect-resolved schema positions and a shared cache. Focused validation tests +cover unused external references, annotations, escaped pointers, anchors, +recursive/dynamic references, embedded dialects, and independent instances. + ## Stage 13: Verify Public Schema Wiring And Native Submission **Catalog:** 6, 15, 21; schema-file half of 32. diff --git a/internal/validate/schema_locations.go b/internal/validate/schema_locations.go new file mode 100644 index 0000000..28b57ec --- /dev/null +++ b/internal/validate/schema_locations.go @@ -0,0 +1,91 @@ +package validate + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// These schema-bearing positions mirror the inherited subschema tables in +// jsonschema/v6 v6.0.2 draft.go. Annotation objects are deliberately excluded. +func checkSchemaLocations(ctx context.Context, compiler *jsonschema.Compiler, resourceURL string, document any, pointer string, compiled *jsonschema.Schema) error { + if err := ctx.Err(); err != nil { + return err + } + object, ok := document.(map[string]any) + if !ok { + return nil + } + version := compiled.DraftVersion + mapKeys := []string{"definitions", "properties", "patternProperties", "dependencies"} + singleKeys := []string{"not", "additionalProperties", "items", "additionalItems"} + arrayKeys := []string{"allOf", "anyOf", "oneOf", "items"} + if version >= 6 { + singleKeys = append(singleKeys, "propertyNames", "contains") + } + if version >= 7 { + singleKeys = append(singleKeys, "if", "then", "else") + } + if version >= 2019 { + mapKeys = append(mapKeys, "$defs", "dependentSchemas") + singleKeys = append(singleKeys, "unevaluatedProperties", "unevaluatedItems", "contentSchema") + } + if version >= 2020 { + arrayKeys = append(arrayKeys, "prefixItems") + } + visit := func(value any, segments ...string) error { + switch value.(type) { + case map[string]any, bool: + default: + return nil + } + childPointer := pointer + for _, segment := range segments { + childPointer += "/" + strings.ReplaceAll(strings.ReplaceAll(segment, "~", "~0"), "/", "~1") + } + parsed, err := url.Parse(resourceURL) + if err != nil { + return err + } + parsed.Fragment = childPointer + child, err := compileJSONSchema(ctx, parsed.String(), compiler.Compile) + if err != nil { + return fmt.Errorf("compile declared schema %q: %w", childPointer, err) + } + return checkSchemaLocations(ctx, compiler, resourceURL, value, childPointer, child) + } + for _, key := range mapKeys { + entries, ok := object[key].(map[string]any) + if !ok { + continue + } + for name, value := range entries { + if err := visit(value, key, name); err != nil { + return err + } + } + } + for _, key := range singleKeys { + if value, ok := object[key]; ok { + if err := visit(value, key); err != nil { + return err + } + } + } + for _, key := range arrayKeys { + entries, ok := object[key].([]any) + if !ok { + continue + } + for i, value := range entries { + if err := visit(value, key, strconv.Itoa(i)); err != nil { + return err + } + } + } + return ctx.Err() +} diff --git a/internal/validate/schema_locations_test.go b/internal/validate/schema_locations_test.go new file mode 100644 index 0000000..dfc3005 --- /dev/null +++ b/internal/validate/schema_locations_test.go @@ -0,0 +1,60 @@ +package validate + +import ( + "context" + "strings" + "testing" +) + +func TestSchemaLocationsCheckUnusedDeclarations(t *testing.T) { + for _, test := range []struct { + name, schema string + wantError bool + }{ + {"unused external", `{"$defs":{"unused":{"$ref":"other.json"}},"type":"string"}`, true}, + {"annotation is data", `{"examples":[{"$ref":"other.json"}],"default":{"$ref":"other.json"},"type":"string"}`, false}, + {"internal escaped key", `{"$defs":{"a/b~c":{"type":"string"}},"$ref":"#/$defs/a~1b~0c"}`, false}, + {"URI escaped key", `{"$defs":{"a # %/雪":{"type":"string"}}}`, false}, + {"internal anchor", `{"$defs":{"value":{"$anchor":"item","type":"string"}},"$ref":"#item"}`, false}, + {"dynamic reference", `{"$dynamicAnchor":"node","type":"object","properties":{"child":{"$dynamicRef":"#node"}}}`, false}, + {"recursive", `{"$id":"https://example.invalid/root","$defs":{"node":{"$anchor":"node","anyOf":[{"type":"null"},{"$ref":"#node"}]}},"$ref":"#/$defs/node"}`, false}, + {"embedded old dialect annotation", `{"$defs":{"old":{"$id":"old","$schema":"http://json-schema.org/draft-07/schema#","$defs":{"unused":{"$ref":"other.json"}},"type":"string"}}}`, false}, + {"embedded old dialect definition", `{"$defs":{"old":{"$id":"old","$schema":"http://json-schema.org/draft-07/schema#","definitions":{"unused":{"$ref":"other.json"}}}}}`, true}, + } { + t.Run(test.name, func(t *testing.T) { + doc, err := decodeJSONValue(context.Background(), []byte(test.schema)) + if err != nil { + t.Fatal(err) + } + _, err = compileSchemaDocument(context.Background(), "promptkit-schema:///root.json", doc) + if test.wantError && (err == nil || !strings.Contains(err.Error(), "external schema resource")) { + t.Fatalf("expected external resource error, got %v", err) + } + if !test.wantError && err != nil { + t.Fatal(err) + } + }) + } +} + +func TestSchemaLocationsPreserveRootValidation(t *testing.T) { + doc, err := decodeJSONValue(context.Background(), []byte(`{"$defs":{"number":{"type":"integer","minimum":2}},"$ref":"#/$defs/number"}`)) + if err != nil { + t.Fatal(err) + } + schema, err := compileSchemaDocument(context.Background(), "promptkit-schema:///root.json", doc) + if err != nil { + t.Fatal(err) + } + if err := schema.Validate(3); err != nil { + t.Fatalf("valid instance: %v", err) + } + if err := schema.Validate("not a number"); err == nil { + t.Fatal("invalid instance was accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := compileSchemaDocument(ctx, "promptkit-schema:///root.json", doc); err != context.Canceled { + t.Fatalf("canceled compilation = %v", err) + } +} diff --git a/internal/validate/standard_validator.go b/internal/validate/standard_validator.go index 004d5fa..f2b1cf9 100644 --- a/internal/validate/standard_validator.go +++ b/internal/validate/standard_validator.go @@ -436,7 +436,14 @@ func compileSchemaDocument(ctx context.Context, resourceURL string, document any } return nil, fmt.Errorf("register schema: %w", err) } - return compileJSONSchema(ctx, resourceURL, compiler.Compile) + root, err := compileJSONSchema(ctx, resourceURL, compiler.Compile) + if err != nil { + return nil, err + } + if err := checkSchemaLocations(ctx, compiler, resourceURL, document, "", root); err != nil { + return nil, err + } + return root, nil } type schemaExecutor interface {