From e056cb160732edbd30f9c338e977be5669333d20 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 13:56:03 +0000 Subject: [PATCH] Share self-contained schema compilation --- docs/roadmap/implementation.md | 4 + internal/validate/cancellation_test.go | 11 +- internal/validate/standard_validator.go | 228 ++++--------------- internal/validate/standard_validator_test.go | 22 +- 4 files changed, 60 insertions(+), 205 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a254c01..4363a06 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -314,6 +314,10 @@ framing, containment, and freezing cases. External-reference successes become expected errors. Root compilation alone is intentionally not the completed self-containment remedy; Stage 12 checks otherwise unused schema declarations. +Completion: host and filesystem validation share root-document compilation with +a context-aware external-resource rejection loader. Focused validation tests +pass on Go 1.27.3; checking unused declarations remains in the following unit. + ## Stage 12: Check Declared Schema Locations Through The Compiler **Catalog:** 6, 21, resource-policy completion. **Scope:** schema-location traversal. diff --git a/internal/validate/cancellation_test.go b/internal/validate/cancellation_test.go index 8a0408d..94afd82 100644 --- a/internal/validate/cancellation_test.go +++ b/internal/validate/cancellation_test.go @@ -35,14 +35,13 @@ func TestValidationCancellationBeforeWorkDoesNotOpenSchemaSource(t *testing.T) { } } -func TestValidationCancellationBetweenReferencedSchemaReadChunks(t *testing.T) { +func TestValidationCancellationBetweenRootSchemaReadChunks(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) source := &controlledSchemaFS{ FS: fstest.MapFS{ - "root.json": {Data: []byte(`{"$ref":"child.json"}`)}, - "child.json": {Data: []byte(`{"type":"string"}` + strings.Repeat(" ", schemaReadChunkSize*2))}, + "root.json": {Data: []byte(`{"type":"string"}` + strings.Repeat(" ", schemaReadChunkSize*2))}, }, - target: "child.json", + target: "root.json", cancel: cancel, } validator := NewFSValidator(source, ".") @@ -57,8 +56,8 @@ func TestValidationCancellationBetweenReferencedSchemaReadChunks(t *testing.T) { if reads := source.reads.Load(); reads != 1 { t.Fatalf("controlled child reads = %d, want 1", reads) } - if closes := source.closes.Load(); closes != 1 { - t.Fatalf("controlled child closes = %d, want 1", closes) + if closes := source.closes.Load(); closes != 2 { + t.Fatalf("controlled root closes = %d, want stat and read handles closed", closes) } } diff --git a/internal/validate/standard_validator.go b/internal/validate/standard_validator.go index fe3e26c..004d5fa 100644 --- a/internal/validate/standard_validator.go +++ b/internal/validate/standard_validator.go @@ -75,51 +75,23 @@ func (v *StandardValidator) PrepareValidation(ctx context.Context, contract doma if err := ctx.Err(); err != nil { return nil, err } - prepared := &preparedValidation{contract: contract} if contract.ValidationMode != domain.ValidationJSONSchema { return prepared, nil } - - resolvedSchemaPath, err := v.resolveSchemaPath(ctx, contract.SchemaPath) + name, err := v.resolveSchemaPath(ctx, contract.SchemaPath) if err != nil { return nil, err } - schemaDocument, err := loadJSONSchemaFile(ctx, resolvedSchemaPath) + document, err := loadJSONSchemaFile(ctx, name) if err != nil { - return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err) + return nil, fmt.Errorf("failed to compile JSON schema %q: %w", name, err) } - schemaRoot, err := v.schemaRoot() + schema, err := compileSchemaDocument(ctx, fileSchemaResourceURL(name).String(), document) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to compile JSON schema %q: %w", name, err) } - if err := ctx.Err(); err != nil { - return nil, err - } - compiler := newSchemaCompiler(standardSchemaLoader{ctx: ctx, root: schemaRoot}) - resourceURL := fileSchemaResourceURL(resolvedSchemaPath) - if err := ctx.Err(); err != nil { - return nil, err - } - if err := compiler.AddResource(resourceURL.String(), schemaDocument); err != nil { - if contextErr := ctx.Err(); contextErr != nil { - return nil, contextErr - } - return nil, fmt.Errorf("failed to register JSON schema %q: %w", resolvedSchemaPath, err) - } - if err := ctx.Err(); err != nil { - return nil, err - } - schema, err := compileJSONSchema(ctx, resourceURL.String(), compiler.Compile) - if err != nil { - return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err) - } - if err := ctx.Err(); err != nil { - return nil, err - } - - prepared.schemaDocument = schemaDocument - prepared.schema = schema + prepared.schemaDocument, prepared.schema = document, schema return prepared, nil } @@ -127,40 +99,19 @@ func (v *FSValidator) PrepareValidation(ctx context.Context, contract domain.Out if err := ctx.Err(); err != nil { return nil, err } - prepared := &preparedValidation{contract: contract} if contract.ValidationMode != domain.ValidationJSONSchema { return prepared, nil } - - schemaName, schemaDocument, err := v.loadSchemaDocument(ctx, contract.SchemaPath) + name, document, err := v.loadSchemaDocument(ctx, contract.SchemaPath) if err != nil { return nil, err } - resourceURL := fsSchemaResourceURL(schemaName) - compiler := newSchemaCompiler(fsSchemaLoader{ctx: ctx, fsys: v.fsys, root: filecatalog.CleanFSRoot(v.root)}) - if err := ctx.Err(); err != nil { - return nil, err - } - if err := compiler.AddResource(resourceURL.String(), schemaDocument); err != nil { - if contextErr := ctx.Err(); contextErr != nil { - return nil, contextErr - } - return nil, fmt.Errorf("failed to register JSON schema %q: %w", schemaName, err) - } - if err := ctx.Err(); err != nil { - return nil, err - } - schema, err := compileJSONSchema(ctx, resourceURL.String(), compiler.Compile) + schema, err := compileSchemaDocument(ctx, fsSchemaResourceURL(name).String(), document) if err != nil { - return nil, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err) + return nil, fmt.Errorf("failed to compile JSON schema %q: %w", name, err) } - if err := ctx.Err(); err != nil { - return nil, err - } - - prepared.schemaDocument = schemaDocument - prepared.schema = schema + prepared.schemaDocument, prepared.schema = document, schema return prepared, nil } @@ -247,56 +198,30 @@ func validateArtifact(ctx context.Context, artifact *domain.Artifact, contract d } func (v *StandardValidator) validateJSONSchema(ctx context.Context, instance any, schemaPath string) ([]string, error) { - resolvedSchemaPath, err := v.resolveSchemaPath(ctx, schemaPath) + name, err := v.resolveSchemaPath(ctx, schemaPath) if err != nil { return nil, err } - - schemaRoot, err := v.schemaRoot() + document, err := loadJSONSchemaFile(ctx, name) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to compile JSON schema %q: %w", name, err) } - if err := ctx.Err(); err != nil { - return nil, err - } - compiler := newSchemaCompiler(standardSchemaLoader{ctx: ctx, root: schemaRoot}) - resourceURL := fileSchemaResourceURL(resolvedSchemaPath) - schema, err := compileJSONSchema(ctx, resourceURL.String(), compiler.Compile) + schema, err := compileSchemaDocument(ctx, fileSchemaResourceURL(name).String(), document) if err != nil { - return nil, fmt.Errorf("failed to compile JSON schema %q: %w", resolvedSchemaPath, err) + return nil, fmt.Errorf("failed to compile JSON schema %q: %w", name, err) } - return executeJSONSchema(ctx, schema, instance) } func (v *FSValidator) validateJSONSchema(ctx context.Context, instance any, schemaPath string) ([]string, error) { - schemaName, schemaDoc, err := v.loadSchemaDocument(ctx, schemaPath) + name, document, err := v.loadSchemaDocument(ctx, schemaPath) if err != nil { return nil, err } - - resourceURL := fsSchemaResourceURL(schemaName) - if err := validateSchemaDialect(schemaDoc); err != nil { - return nil, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err) - } - compiler := newSchemaCompiler(fsSchemaLoader{ctx: ctx, fsys: v.fsys, root: filecatalog.CleanFSRoot(v.root)}) - if err := ctx.Err(); err != nil { - return nil, err - } - if err := compiler.AddResource(resourceURL.String(), schemaDoc); err != nil { - if contextErr := ctx.Err(); contextErr != nil { - return nil, contextErr - } - return nil, fmt.Errorf("failed to register JSON schema %q: %w", schemaName, err) - } - if err := ctx.Err(); err != nil { - return nil, err - } - schema, err := compileJSONSchema(ctx, resourceURL.String(), compiler.Compile) + schema, err := compileSchemaDocument(ctx, fsSchemaResourceURL(name).String(), document) if err != nil { - return nil, fmt.Errorf("failed to compile JSON schema %q: %w", schemaName, err) + return nil, fmt.Errorf("failed to compile JSON schema %q: %w", name, err) } - return executeJSONSchema(ctx, schema, instance) } @@ -489,11 +414,29 @@ func fsSchemaResourceURL(schemaName string) *url.URL { } } -func newSchemaCompiler(loader jsonschema.URLLoader) *jsonschema.Compiler { +type externalSchemaLoader struct{ ctx context.Context } + +func (l externalSchemaLoader) Load(resourceURL string) (any, error) { + if err := l.ctx.Err(); err != nil { + return nil, err + } + return nil, fmt.Errorf("external schema resource %q is not supported; use a self-contained schema", resourceURL) +} + +func compileSchemaDocument(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) { + if err := ctx.Err(); err != nil { + return nil, err + } compiler := jsonschema.NewCompiler() compiler.DefaultDraft(jsonschema.Draft2020) - compiler.UseLoader(loader) - return compiler + compiler.UseLoader(externalSchemaLoader{ctx: ctx}) + if err := compiler.AddResource(resourceURL, document); err != nil { + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr + } + return nil, fmt.Errorf("register schema: %w", err) + } + return compileJSONSchema(ctx, resourceURL, compiler.Compile) } type schemaExecutor interface { @@ -544,36 +487,6 @@ func validateSchemaDialect(doc any) error { return nil } -type standardSchemaLoader struct { - ctx context.Context - root string -} - -func (l standardSchemaLoader) Load(resourceURL string) (any, error) { - if err := l.ctx.Err(); err != nil { - return nil, err - } - parsed, err := url.Parse(resourceURL) - if err != nil || parsed.Scheme != "file" || parsed.Host != "" || parsed.RawQuery != "" || parsed.Opaque != "" { - return nil, fmt.Errorf("schema reference %q is not a contained file reference", resourceURL) - } - fileName, err := (jsonschema.FileLoader{}).ToFile(resourceURL) - if err != nil { - return nil, fmt.Errorf("schema reference %q is not a contained file reference: %w", resourceURL, err) - } - resolved, err := containedFilesystemPath(l.root, fileName) - if err != nil { - if contextErr := l.ctx.Err(); contextErr != nil { - return nil, contextErr - } - return nil, err - } - if err := l.ctx.Err(); err != nil { - return nil, err - } - return loadJSONSchemaFile(l.ctx, resolved) -} - func containedFilesystemPath(root, name string) (string, error) { candidate := name if !filepath.IsAbs(candidate) { @@ -666,66 +579,3 @@ func readSchemaFile(ctx context.Context, open func() (fs.File, error)) ([]byte, } } } - -type fsSchemaLoader struct { - ctx context.Context - fsys fs.FS - root string -} - -func (l fsSchemaLoader) Load(resourceURL string) (any, error) { - if err := l.ctx.Err(); err != nil { - return nil, err - } - parsed, err := url.Parse(resourceURL) - if err != nil { - return nil, fmt.Errorf("invalid schema reference %q: %w", resourceURL, err) - } - if parsed.Scheme != "promptkit-schema" || parsed.Host != "" || parsed.RawQuery != "" || parsed.Opaque != "" { - return nil, fmt.Errorf("schema reference %q is not allowed", resourceURL) - } - name := strings.TrimPrefix(parsed.Path, "/") - name = path.Clean(name) - if l.root == "." { - if strings.HasPrefix(name, "../") || name == ".." { - return nil, fmt.Errorf("schema reference %q escapes source root", resourceURL) - } - } else if name != l.root && !strings.HasPrefix(name, l.root+"/") { - return nil, fmt.Errorf("schema reference %q escapes source root", resourceURL) - } - - rootInfo, err := fs.Stat(l.fsys, l.root) - if err != nil { - if contextErr := l.ctx.Err(); contextErr != nil { - return nil, contextErr - } - return nil, err - } - if err := l.ctx.Err(); err != nil { - return nil, err - } - if !rootInfo.IsDir() && name != l.root { - return nil, fmt.Errorf("schema reference %q is outside the configured schema file", resourceURL) - } - - raw, err := readSchemaFile(l.ctx, func() (fs.File, error) { - return l.fsys.Open(name) - }) - if err != nil { - return nil, err - } - doc, err := decodeJSONValue(l.ctx, raw) - if err != nil { - return nil, err - } - if err := validateSchemaDialect(doc); err != nil { - if contextErr := l.ctx.Err(); contextErr != nil { - return nil, contextErr - } - return nil, err - } - if err := l.ctx.Err(); err != nil { - return nil, err - } - return doc, nil -} diff --git a/internal/validate/standard_validator_test.go b/internal/validate/standard_validator_test.go index 159487f..b094920 100644 --- a/internal/validate/standard_validator_test.go +++ b/internal/validate/standard_validator_test.go @@ -232,8 +232,9 @@ func TestStandardValidatorPreparedSchemaSurvivesSourceRemoval(t *testing.T) { "title": "original root", "type": "object", "required": ["value"], + "$defs": {"value": {"type":"integer", "minimum":2}}, "properties": { - "value": {"$ref": "value.json"} + "value": {"$ref": "#/$defs/value"} } }`) rootPath := filepath.Join(tmp, "schema.json") @@ -415,8 +416,9 @@ func TestFSValidatorPreparedSchemaSurvivesSourceMutation(t *testing.T) { "title": "original root", "type": "object", "required": ["value"], + "$defs": {"value": {"type":"integer", "minimum":2}}, "properties": { - "value": {"$ref": "value.json"} + "value": {"$ref": "#/$defs/value"} } }`) fsys := fstest.MapFS{ @@ -525,8 +527,8 @@ func assertSchemaValidation(t *testing.T, validator Validator, schemaPath string ValidationMode: domain.ValidationJSONSchema, SchemaPath: schemaPath, }) - if err != nil || !result.IsValid { - t.Fatalf("validate schema %q: result=%#v error=%v", schemaPath, result, err) + if err == nil || !strings.Contains(err.Error(), "external schema resource") { + t.Fatalf("external reference schema %q: result=%#v error=%v", schemaPath, result, err) } } @@ -631,9 +633,9 @@ func TestStandardValidatorJSONSchemaReferenceBoundaries(t *testing.T) { wantError string writeOuter bool }{ - {name: "contained relative reference", reference: "child.json"}, - {name: "remote reference", reference: "https://example.test/schema.json", wantError: "not a contained file reference"}, - {name: "escaping reference", reference: "../outside.json", wantError: "escapes source root", writeOuter: true}, + {name: "contained relative reference", reference: "child.json", wantError: "external schema resource"}, + {name: "remote reference", reference: "https://example.test/schema.json", wantError: "external schema resource"}, + {name: "escaping reference", reference: "../outside.json", wantError: "external schema resource", writeOuter: true}, } for _, tc := range tests { @@ -676,9 +678,9 @@ func TestFSValidatorJSONSchemaReferenceBoundaries(t *testing.T) { wantError string }{ {name: "same document fragment", reference: "#/$defs/value"}, - {name: "contained relative reference", reference: "child.json"}, - {name: "remote reference", reference: "https://example.test/schema.json", wantError: "is not allowed"}, - {name: "escaping reference", reference: "../outside.json", wantError: "escapes source root"}, + {name: "contained relative reference", reference: "child.json", wantError: "external schema resource"}, + {name: "remote reference", reference: "https://example.test/schema.json", wantError: "external schema resource"}, + {name: "escaping reference", reference: "../outside.json", wantError: "external schema resource"}, } for _, tc := range tests {