Validate schemas without relying on source filenames
This commit is contained in:
@@ -421,6 +421,8 @@ Schemas are JSON documents selected by a prompt or request `schema_path`.
|
||||
Directory and `fs.FS` schema paths resolve within the configured root. Each
|
||||
schema must be self-contained: in-document references, IDs, and anchors are
|
||||
supported, but references requiring another file or network resource fail.
|
||||
References must also work without the source filename as a base; use fragment
|
||||
references or explicit schema IDs rather than relying on that filename.
|
||||
`WithSchemaFile` exposes one schema under its base name. An unreadable,
|
||||
invalid, or unresolvable schema causes an operational validation error.
|
||||
Valid JSON that fails the schema instead yields a failed validation result.
|
||||
|
||||
@@ -72,7 +72,12 @@ existing cancellation policy.
|
||||
|
||||
### 2. Check Schema Self-Containment Independently Of Its Source Filename [P2]
|
||||
|
||||
**Status:** Open. **Stages:** 11–13.
|
||||
**Status:** Complete. **Stages:** 11–13.
|
||||
|
||||
Completion: shared compilation checks both the source base and an unrelated
|
||||
submission base through the same compiler rules, including unused declarations.
|
||||
Host and filesystem regressions preserve fragment, explicit-ID, and embedded-ID
|
||||
references while rejecting unsent source-name dependencies.
|
||||
|
||||
**Locations:** [schema compilation](../../internal/validate/standard_validator.go),
|
||||
`compileSchemaDocument`, lines 426–445, and its host/`fs.FS` callers;
|
||||
|
||||
46
internal/validate/schema_portability_test.go
Normal file
46
internal/validate/schema_portability_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestSchemaPreparationDoesNotDependOnSourceName(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, schema string
|
||||
rejected bool
|
||||
}{
|
||||
{"source name", `{"$defs":{"v":{"type":"integer"}},"$ref":"schema.json#/$defs/v"}`, true},
|
||||
{"unused source name", `{"$defs":{"v":{"$ref":"schema.json"}}}`, true},
|
||||
{"fragment", `{"$defs":{"v":{"type":"integer"}},"$ref":"#/$defs/v"}`, false},
|
||||
{"explicit ID", `{"$id":"https://example.invalid/schema.json","$defs":{"v":{"type":"integer"}},"$ref":"schema.json#/$defs/v"}`, false},
|
||||
{"embedded ID", `{"$defs":{"v":{"$id":"child","type":"integer"}},"$ref":"child"}`, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "schema.json"), []byte(test.schema), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fsys := fstest.MapFS{"schema.json": &fstest.MapFile{Data: []byte(test.schema)}}
|
||||
for i, validator := range []ValidationPreparer{NewStandardValidator(dir), NewFSValidator(fsys, ".")} {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
_, err := validator.PrepareValidation(context.Background(), domain.OutputContract{Format: domain.FormatJSON, ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json"})
|
||||
if test.rejected {
|
||||
if err == nil || !strings.Contains(err.Error(), "source location") {
|
||||
t.Fatalf("expected source-independent rejection, got %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -424,6 +424,20 @@ func (l externalSchemaLoader) Load(resourceURL string) (any, error) {
|
||||
}
|
||||
|
||||
func compileSchemaDocument(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) {
|
||||
root, err := compileSchemaAt(ctx, resourceURL, document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// File and private-FS retrieval URLs are never available to the provider.
|
||||
// Compiling again under an unrelated HTTPS base detects reliance on those
|
||||
// aliases without rewriting references or duplicating URI resolution rules.
|
||||
if _, err := compileSchemaAt(ctx, "https://promptkit.invalid/submitted-schema", document); err != nil {
|
||||
return nil, fmt.Errorf("schema is not independent of its source location; use fragment references or explicit schema IDs: %w", err)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func compileSchemaAt(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -11,10 +12,17 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
type rejectWireSchemaLoad struct{}
|
||||
|
||||
func (rejectWireSchemaLoad) Load(uri string) (any, error) {
|
||||
return nil, fmt.Errorf("unexpected external resource %s", uri)
|
||||
}
|
||||
|
||||
func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) {
|
||||
firstDir, secondDir := t.TempDir(), t.TempDir()
|
||||
first, second := filepath.Join(firstDir, "schema.json"), filepath.Join(secondDir, "schema.json")
|
||||
@@ -57,6 +65,16 @@ func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) {
|
||||
if err != nil || !strings.Contains(string(encoded), `"$defs"`) || !strings.Contains(string(encoded), `#/$defs/value`) {
|
||||
t.Fatalf("wire schema = %s, %v", encoded, err)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.UseLoader(rejectWireSchemaLoad{})
|
||||
const submission = "https://provider.invalid/request-schema"
|
||||
document := payload["response_format"].(map[string]any)["json_schema"].(map[string]any)["schema"]
|
||||
if err := compiler.AddResource(submission, document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := compiler.Compile(submission); err != nil {
|
||||
t.Fatalf("outbound schema needs local source context: %v", err)
|
||||
}
|
||||
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrValidation) {
|
||||
t.Fatalf("missing root error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user