From 7f82a6e1ca9d30d9f8e27e57d37922f360b15235 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 22:02:11 +0330 Subject: [PATCH 01/13] feat(internal/cli/run_artifact_schema.go): add JSON schema validator for required run artifacts --- internal/cli/run_artifact_schema.go | 314 ++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 internal/cli/run_artifact_schema.go diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go new file mode 100644 index 000000000..56028d5bf --- /dev/null +++ b/internal/cli/run_artifact_schema.go @@ -0,0 +1,314 @@ +package cli + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "math" + "os" + "reflect" + "sort" + "strings" +) + +type artifactSchema struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties map[string]artifactSchema `json:"properties"` + Items *artifactSchema `json:"items"` + Enum []interface{} `json:"enum"` +} + +type schemaViolation struct { + Path string + Keyword string + Message string +} + +func (v schemaViolation) String() string { + loc := v.Path + if loc == "" { + loc = "(root)" + } + return loc + ": " + v.Message +} + +type SchemaValidationResult struct { + Artifact string `json:"artifact"` + Schema string `json:"schema,omitempty"` + Valid bool `json:"valid"` + Violations []string `json:"violations,omitempty"` + Error string `json:"error,omitempty"` +} + +var knownSchemaTypes = map[string]bool{ + "object": true, + "array": true, + "string": true, + "number": true, + "integer": true, + "boolean": true, + "null": true, +} + +func parseArtifactSchema(data []byte) (artifactSchema, error) { + var s artifactSchema + if err := json.Unmarshal(data, &s); err != nil { + return artifactSchema{}, fmt.Errorf("invalid JSON: %w", err) + } + if err := validateSchemaShape(s, ""); err != nil { + return artifactSchema{}, err + } + return s, nil +} + +func validateSchemaShape(s artifactSchema, path string) error { + if s.Type != "" && !knownSchemaTypes[s.Type] { + return fmt.Errorf("unknown type %q at %s", s.Type, schemaPathOrRoot(path)) + } + for _, key := range sortedSchemaKeys(s.Properties) { + if err := validateSchemaShape(s.Properties[key], schemaJoinPath(path, key)); err != nil { + return err + } + } + if s.Items != nil { + if err := validateSchemaShape(*s.Items, path+"[]"); err != nil { + return err + } + } + return nil +} + +func validateJSONAgainstSchema(doc []byte, schema artifactSchema) []schemaViolation { + var value interface{} + if err := json.Unmarshal(doc, &value); err != nil { + return []schemaViolation{{Keyword: "json", Message: fmt.Sprintf("artifact is not valid JSON: %v", err)}} + } + var out []schemaViolation + validateSchemaValue(value, schema, "", &out) + return out +} + +func validateSchemaValue(value interface{}, schema artifactSchema, path string, out *[]schemaViolation) { + if schema.Type != "" && !schemaTypeMatches(schema.Type, value) { + *out = append(*out, schemaViolation{ + Path: path, + Keyword: "type", + Message: fmt.Sprintf("expected type %s, got %s", schema.Type, schemaTypeName(value)), + }) + return + } + if len(schema.Enum) > 0 && !schemaEnumContains(schema.Enum, value) { + *out = append(*out, schemaViolation{ + Path: path, + Keyword: "enum", + Message: fmt.Sprintf("value %s is not one of the allowed values", schemaCompactJSON(value)), + }) + return + } + switch v := value.(type) { + case map[string]interface{}: + for _, req := range schema.Required { + if _, ok := v[req]; !ok { + *out = append(*out, schemaViolation{ + Path: schemaJoinPath(path, req), + Keyword: "required", + Message: fmt.Sprintf("missing required property %q", req), + }) + } + } + for _, key := range sortedSchemaKeys(schema.Properties) { + if child, ok := v[key]; ok { + validateSchemaValue(child, schema.Properties[key], schemaJoinPath(path, key), out) + } + } + case []interface{}: + if schema.Items != nil { + for i, item := range v { + validateSchemaValue(item, *schema.Items, fmt.Sprintf("%s[%d]", path, i), out) + } + } + } +} + +func schemaTypeMatches(t string, value interface{}) bool { + switch t { + case "object": + _, ok := value.(map[string]interface{}) + return ok + case "array": + _, ok := value.([]interface{}) + return ok + case "string": + _, ok := value.(string) + return ok + case "boolean": + _, ok := value.(bool) + return ok + case "null": + return value == nil + case "number": + _, ok := value.(float64) + return ok + case "integer": + f, ok := value.(float64) + return ok && f == math.Trunc(f) + default: + return true + } +} + +func schemaTypeName(value interface{}) string { + switch value.(type) { + case map[string]interface{}: + return "object" + case []interface{}: + return "array" + case string: + return "string" + case bool: + return "boolean" + case float64: + return "number" + case nil: + return "null" + default: + return fmt.Sprintf("%T", value) + } +} + +func schemaEnumContains(enum []interface{}, value interface{}) bool { + for _, candidate := range enum { + if reflect.DeepEqual(candidate, value) { + return true + } + } + return false +} + +func schemaCompactJSON(value interface{}) string { + b, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(b) +} + +func schemaJoinPath(base, key string) string { + if base == "" { + return key + } + return base + "." + key +} + +func schemaPathOrRoot(path string) string { + if path == "" { + return "(root)" + } + return path +} + +func sortedSchemaKeys(m map[string]artifactSchema) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +type loadedArtifactSchema struct { + remote string + schemaPath string + schema artifactSchema +} + +func parseRequireArtifactSchemaSpec(value string) (remote, schemaPath string, err error) { + remote, schemaPath, ok := strings.Cut(strings.TrimSpace(value), "=") + remote = strings.TrimSpace(remote) + schemaPath = strings.TrimSpace(schemaPath) + if !ok || remote == "" || schemaPath == "" { + return "", "", exit(2, "--require-artifact-schema expects remote=schema.json") + } + if err := validateRequiredRunArtifactGlobs([]string{remote}); err != nil { + return "", "", err + } + return remote, schemaPath, nil +} + +func loadRequireArtifactSchemas(values []string) ([]loadedArtifactSchema, error) { + out := make([]loadedArtifactSchema, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + remote, schemaPath, err := parseRequireArtifactSchemaSpec(value) + if err != nil { + return nil, err + } + if seen[remote] { + return nil, exit(2, "--require-artifact-schema lists %q more than once", remote) + } + seen[remote] = true + data, err := os.ReadFile(schemaPath) + if err != nil { + return nil, exit(2, "--require-artifact-schema: read schema %s: %v", schemaPath, err) + } + schema, err := parseArtifactSchema(data) + if err != nil { + return nil, exit(2, "--require-artifact-schema: invalid schema %s: %v", schemaPath, err) + } + out = append(out, loadedArtifactSchema{remote: remote, schemaPath: schemaPath, schema: schema}) + } + return out, nil +} + +func validateRemoteArtifactSchemas(ctx context.Context, target SSHTarget, workdir string, schemas []loadedArtifactSchema) ([]SchemaValidationResult, string, error) { + results := make([]SchemaValidationResult, 0, len(schemas)) + var lines []string + var firstFailure error + for _, s := range schemas { + result := SchemaValidationResult{Artifact: s.remote, Schema: s.schemaPath} + data, err := readRemoteArtifactBytes(ctx, target, workdir, s.remote) + if err != nil { + result.Valid = false + result.Error = "fetch failed" + results = append(results, result) + lines = append(lines, fmt.Sprintf("schema %s: fetch failed: %v", s.remote, err)) + if firstFailure == nil { + firstFailure = exit(7, "require artifact schema: fetch %s: %v", s.remote, err) + } + continue + } + violations := validateJSONAgainstSchema(data, s.schema) + result.Valid = len(violations) == 0 + if result.Valid { + results = append(results, result) + lines = append(lines, fmt.Sprintf("schema %s: ok (%s)", s.remote, s.schemaPath)) + continue + } + for _, v := range violations { + result.Violations = append(result.Violations, v.String()) + } + results = append(results, result) + lines = append(lines, fmt.Sprintf("schema %s: failed %d check(s) against %s:", s.remote, len(violations), s.schemaPath)) + for _, v := range violations { + lines = append(lines, " - "+v.String()) + } + if firstFailure == nil { + firstFailure = exit(7, "artifact schema validation failed: %s (%d violation(s))", s.remote, len(violations)) + } + } + return results, strings.Join(lines, "\n"), firstFailure +} + +func readRemoteArtifactBytes(ctx context.Context, target SSHTarget, workdir, remote string) ([]byte, error) { + encoded, err := runSSHOutput(ctx, target, remoteDownloadBase64Command(target, workdir, remote)) + if err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(encoded), "")) + if err != nil { + return nil, fmt.Errorf("decode base64: %w", err) + } + return data, nil +} From 43852be93a7e22144f155bf3fe99622d1389abce Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 22:02:58 +0330 Subject: [PATCH 02/13] test(internal/cli/run_artifact_schema_test.go): add tests for artifact schema validation and parsing --- internal/cli/run_artifact_schema_test.go | 234 +++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 internal/cli/run_artifact_schema_test.go diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go new file mode 100644 index 000000000..912821857 --- /dev/null +++ b/internal/cli/run_artifact_schema_test.go @@ -0,0 +1,234 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseArtifactSchema(t *testing.T) { + t.Run("valid nested schema with ignored unknown keywords", func(t *testing.T) { + data := []byte(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ignored", + "type": "object", + "required": ["status", "items"], + "properties": { + "status": {"type": "string", "enum": ["passed", "failed"]}, + "count": {"type": "integer"}, + "items": {"type": "array", "items": {"type": "object", "required": ["name"]}} + } + }`) + schema, err := parseArtifactSchema(data) + if err != nil { + t.Fatalf("parseArtifactSchema() unexpected error: %v", err) + } + if schema.Type != "object" || len(schema.Required) != 2 { + t.Fatalf("parsed schema shape wrong: %+v", schema) + } + if schema.Properties["items"].Items == nil { + t.Fatalf("nested array items schema not parsed") + } + }) + + t.Run("invalid JSON is rejected", func(t *testing.T) { + if _, err := parseArtifactSchema([]byte(`{"type":`)); err == nil { + t.Fatalf("expected error for malformed schema JSON") + } + }) + + t.Run("unknown type keyword is rejected", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{"type": "timestamp"}`)) + if err == nil || !strings.Contains(err.Error(), "unknown type") { + t.Fatalf("expected unknown-type error, got %v", err) + } + }) + + t.Run("unknown nested type keyword is rejected", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{"type":"object","properties":{"x":{"type":"date"}}}`)) + if err == nil || !strings.Contains(err.Error(), "x") { + t.Fatalf("expected nested unknown-type error naming path, got %v", err) + } + }) +} + +func TestValidateJSONAgainstSchema(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "type": "object", + "required": ["status", "count", "items"], + "properties": { + "status": {"type": "string", "enum": ["passed", "failed"]}, + "count": {"type": "integer"}, + "config": {"type": "object", "required": ["retries"], "properties": {"retries": {"type": "number"}}}, + "items": {"type": "array", "items": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}} + } + }`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + + tests := []struct { + name string + doc string + wantOK bool + wantPath string + }{ + { + name: "valid document", + doc: `{"status":"passed","count":3,"items":[{"name":"a"},{"name":"b"}]}`, + wantOK: true, + }, + { + name: "missing required field", + doc: `{"status":"passed","items":[]}`, + wantPath: "count", + }, + { + name: "wrong scalar type", + doc: `{"status":"passed","count":"three","items":[]}`, + wantPath: "count", + }, + { + name: "enum mismatch", + doc: `{"status":"skipped","count":1,"items":[]}`, + wantPath: "status", + }, + { + name: "nested object property wrong type", + doc: `{"status":"passed","count":1,"items":[],"config":{"retries":"nope"}}`, + wantPath: "config.retries", + }, + { + name: "array element violation reports index path", + doc: `{"status":"passed","count":1,"items":[{"name":"ok"},{"nope":true}]}`, + wantPath: "items[1].name", + }, + { + name: "non-JSON document is a single violation, not a crash", + doc: `this is not json`, + wantPath: "", + }, + { + name: "empty document is a violation", + doc: ``, + wantPath: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + violations := validateJSONAgainstSchema([]byte(tc.doc), schema) + if tc.wantOK { + if len(violations) != 0 { + t.Fatalf("expected no violations, got %v", violations) + } + return + } + if len(violations) == 0 { + t.Fatalf("expected a violation, got none") + } + found := false + for _, v := range violations { + if v.Path == tc.wantPath { + found = true + break + } + } + if !found { + t.Fatalf("expected a violation at path %q, got %v", tc.wantPath, violations) + } + }) + } +} + +func TestValidateJSONAgainstSchemaTypeMismatchDoesNotCascade(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"object","required":["a"],"properties":{"a":{"type":"string"}}}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`"a bare string"`), schema) + if len(violations) != 1 || violations[0].Keyword != "type" { + t.Fatalf("expected exactly one type violation, got %v", violations) + } +} + +func TestParseRequireArtifactSchemaSpec(t *testing.T) { + t.Run("valid spec", func(t *testing.T) { + remote, schema, err := parseRequireArtifactSchemaSpec("reports/out.json=schema.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if remote != "reports/out.json" || schema != "schema.json" { + t.Fatalf("parsed spec wrong: remote=%q schema=%q", remote, schema) + } + }) + + t.Run("missing equals is rejected", func(t *testing.T) { + _, _, err := parseRequireArtifactSchemaSpec("reports/out.json") + if err == nil { + t.Fatalf("expected error for spec without '='") + } + assertExitCode(t, err, 2) + }) + + t.Run("unsafe remote path is rejected", func(t *testing.T) { + if _, _, err := parseRequireArtifactSchemaSpec("/etc/passwd=schema.json"); err == nil { + t.Fatalf("expected error for absolute remote path") + } + if _, _, err := parseRequireArtifactSchemaSpec("../secret.json=schema.json"); err == nil { + t.Fatalf("expected error for parent-escaping remote path") + } + }) +} + +func TestLoadRequireArtifactSchemas(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good.schema.json") + if err := os.WriteFile(good, []byte(`{"type":"object","required":["x"]}`), 0o600); err != nil { + t.Fatalf("write schema: %v", err) + } + bad := filepath.Join(dir, "bad.schema.json") + if err := os.WriteFile(bad, []byte(`{"type":`), 0o600); err != nil { + t.Fatalf("write schema: %v", err) + } + + t.Run("loads valid schema", func(t *testing.T) { + loaded, err := loadRequireArtifactSchemas([]string{"out.json=" + good}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(loaded) != 1 || loaded[0].remote != "out.json" { + t.Fatalf("loaded wrong: %+v", loaded) + } + }) + + t.Run("missing schema file is exit 2", func(t *testing.T) { + _, err := loadRequireArtifactSchemas([]string{"out.json=" + filepath.Join(dir, "nope.json")}) + assertExitCode(t, err, 2) + }) + + t.Run("malformed schema file is exit 2", func(t *testing.T) { + _, err := loadRequireArtifactSchemas([]string{"out.json=" + bad}) + assertExitCode(t, err, 2) + }) + + t.Run("duplicate remote is exit 2", func(t *testing.T) { + _, err := loadRequireArtifactSchemas([]string{"out.json=" + good, "out.json=" + good}) + assertExitCode(t, err, 2) + }) +} + +func assertExitCode(t *testing.T, err error, want int) { + t.Helper() + if err == nil { + t.Fatalf("expected error with exit code %d, got nil", want) + } + var exitErr ExitError + if !AsExitError(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if exitErr.Code != want { + t.Fatalf("exit code = %d, want %d (%v)", exitErr.Code, want, err) + } +} From 3f8d048f63412006bae4d30af61b7ed91ce37b36 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 22:04:22 +0330 Subject: [PATCH 03/13] feat(internal/cli/run.go): add --require-artifact-schema run validation --- internal/cli/run.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/cli/run.go b/internal/cli/run.go index 909abf66b..b12c528c8 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -244,12 +244,14 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b var presetVars stringListFlag var artifactGlobs stringListFlag var requiredArtifactGlobs stringListFlag + var requiredArtifactSchemas stringListFlag fs.Var(&downloads, "download", "download a remote file after command success: remote=local; repeatable") fs.Var(&allowEnvFlags, "allow-env", "allow an environment variable for this run; repeatable or comma-separated") fs.Var(&envProfileFlags, "env-from-profile", "load allowed environment values from a local profile file; repeatable") fs.Var(&presetVars, "preset-var", "preset template variable name=value; repeatable or comma-separated") fs.Var(&artifactGlobs, "artifact-glob", "collect remote files matching a safe glob into a local run artifact tarball; repeatable") fs.Var(&requiredArtifactGlobs, "require-artifact", "require a remote file matching a safe glob after command success; repeatable") + fs.Var(&requiredArtifactSchemas, "require-artifact-schema", "validate a required artifact's JSON content against a schema file after command success: remote=schema.json; repeatable") reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo") timingJSON := fs.Bool("timing-json", false, "print final timing as JSON") timingRecord := fs.String("timing-record", "", "append final timing to benchmark JSONL store: default, off, or path") @@ -380,6 +382,11 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b if err := validateRequiredRunArtifactGlobs(requiredArtifactGlobs); err != nil { return err } + requiredArtifactSchemas = appendUniqueStrings(nil, requiredArtifactSchemas...) + loadedArtifactSchemas, err := loadRequireArtifactSchemas(requiredArtifactSchemas) + if err != nil { + return err + } runArtifactGlobs := appendUniqueStrings(append([]string{}, expansion.ArtifactGlobs...), requiredArtifactGlobs...) if *syncOnly { if len(expansion.ArtifactGlobs) > 0 { @@ -388,6 +395,9 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b if len(requiredArtifactGlobs) > 0 { return exit(2, "--require-artifact cannot be combined with --sync-only") } + if len(requiredArtifactSchemas) > 0 { + return exit(2, "--require-artifact-schema cannot be combined with --sync-only") + } if strings.TrimSpace(*emitProof) != "" { return exit(2, "--emit-proof cannot be combined with --sync-only") } @@ -605,6 +615,9 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b if strings.TrimSpace(*readyPool) != "" { return exit(2, "--pool requires a brokered SSH lease provider") } + if len(requiredArtifactSchemas) > 0 { + return exit(2, "--require-artifact-schema is not supported for provider=%s yet; use an SSH-backed provider", backend.Spec().Name) + } if expansion.Profile.Doctor.Enabled { return exit(2, "%s delegates run execution; profile doctor is not supported", backend.Spec().Name) } @@ -1564,6 +1577,7 @@ afterSync: } } var artifactFailure error + var schemaValidationResults []SchemaValidationResult if code == 0 && len(requiredArtifactGlobs) > 0 { requireOutput, err := requireRunArtifactGlobs(ctx, target, workdir, requiredArtifactGlobs) if err != nil { @@ -1574,6 +1588,17 @@ afterSync: fmt.Fprintln(a.Stderr, strings.TrimSpace(requireOutput)) } } + if code == 0 && len(loadedArtifactSchemas) > 0 { + results, schemaOutput, schemaErr := validateRemoteArtifactSchemas(ctx, target, workdir, loadedArtifactSchemas) + schemaValidationResults = results + if strings.TrimSpace(schemaOutput) != "" { + fmt.Fprintln(a.Stderr, strings.TrimSpace(schemaOutput)) + } + if schemaErr != nil { + artifactFailure = schemaErr + code = 7 + } + } if code == 0 { for _, spec := range downloads { bytes, local, err := downloadRemoteFile(ctx, target, workdir, spec) @@ -1621,6 +1646,7 @@ afterSync: report := timingReportFromRunWithActionsURL(cfg.Provider, leaseID, serverSlug(server), timings, total, code, actionsURL) populateRunTimingMetadata(&report, cfg, repo, server, leaseID, recorder.runID, workdir, runArtifacts) report.Label = runLabelValue + report.SchemaValidations = schemaValidationResults if strings.TrimSpace(*emitProof) != "" && code == 0 { template := cfg.ProofTemplates[strings.TrimSpace(*proofTemplate)] proof, err := writeRunProof(strings.TrimSpace(*emitProof), strings.TrimSpace(*proofTemplate), proofRenderInput{ From f4597d4842aa432324ea3acdfa9820b178d32fc8 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 22:04:49 +0330 Subject: [PATCH 04/13] feat(internal/cli/timing.go): include schema validation results in timing reports --- internal/cli/timing.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/cli/timing.go b/internal/cli/timing.go index 94100e96b..c6f8d3b51 100644 --- a/internal/cli/timing.go +++ b/internal/cli/timing.go @@ -33,8 +33,11 @@ type TimingReport struct { BlockedStage string `json:"blockedStage,omitempty"` RetryLikely string `json:"retryLikely,omitempty"` Artifacts []runArtifact `json:"artifacts,omitempty"` - LeaseStopped *bool `json:"leaseStopped,omitempty"` - LeaseStopErr string `json:"leaseStopError,omitempty"` + + SchemaValidations []SchemaValidationResult `json:"schemaValidations,omitempty"` + + LeaseStopped *bool `json:"leaseStopped,omitempty"` + LeaseStopErr string `json:"leaseStopError,omitempty"` } type TimingPhase struct { From 34e32f13ee26adf193b89baee1b24b4d6d1672b5 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 22:06:01 +0330 Subject: [PATCH 05/13] docs(docs/features/artifacts.md): document --require-artifact-schema --- docs/features/artifacts.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/features/artifacts.md b/docs/features/artifacts.md index 795e0aa41..ecb4efa06 100644 --- a/docs/features/artifacts.md +++ b/docs/features/artifacts.md @@ -38,6 +38,18 @@ safety scanner. Keep required artifacts bounded and scrubbed, such as manifests, summaries, screenshots, or QA reports. Do not use run artifacts for raw datasets, secrets, credentials, signed URLs, or unredacted customer rows. +`--require-artifact-schema remote=schema.json` goes one step further than the +existence guard: after command success it fetches the named JSON artifact from +the lease and validates its content against a local schema file, failing the run +with `exit 7` when the artifact is missing, unparseable, or does not match. The +schema is a small dependency-free subset of JSON Schema (`type`, `required`, +`properties`, `items`, `enum`); unknown keywords are ignored, so a full JSON +Schema document works and richer keywords can be added later. A malformed schema +file fails fast at preflight with `exit 2`. Each result is recorded on the timing +report under `schemaValidations`. The flag is repeatable and, in this first +phase, is supported on SSH-backed providers only; delegated-run providers reject +it until they expose an explicit capability. + Delegated providers reject run artifact collection until they grow an explicit bounded artifact capability. Archive-capable adapters may validate and collect required artifacts and artifact globs. Download-capable adapters may materialize From f5744b0d62e133d5821a1e33928fbfeb7b7972a1 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:00:01 +0330 Subject: [PATCH 06/13] fix(internal/cli/run_artifact_schema.go): fixing up the artifacts schema validator for latest PR reviews --- internal/cli/run_artifact_schema.go | 53 ++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go index 56028d5bf..b83709ab1 100644 --- a/internal/cli/run_artifact_schema.go +++ b/internal/cli/run_artifact_schema.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -18,6 +19,14 @@ type artifactSchema struct { Properties map[string]artifactSchema `json:"properties"` Items *artifactSchema `json:"items"` Enum []interface{} `json:"enum"` + AnnotationSchema json.RawMessage `json:"$schema,omitempty"` + AnnotationID json.RawMessage `json:"$id,omitempty"` + AnnotationComment json.RawMessage `json:"$comment,omitempty"` + AnnotationTitle json.RawMessage `json:"title,omitempty"` + AnnotationDescription json.RawMessage `json:"description,omitempty"` + AnnotationExamples json.RawMessage `json:"examples,omitempty"` + AnnotationDefault json.RawMessage `json:"default,omitempty"` + AnnotationDeprecated json.RawMessage `json:"deprecated,omitempty"` } type schemaViolation struct { @@ -53,9 +62,11 @@ var knownSchemaTypes = map[string]bool{ } func parseArtifactSchema(data []byte) (artifactSchema, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() var s artifactSchema - if err := json.Unmarshal(data, &s); err != nil { - return artifactSchema{}, fmt.Errorf("invalid JSON: %w", err) + if err := decoder.Decode(&s); err != nil { + return artifactSchema{}, fmt.Errorf("%w (supported keywords: type, required, properties, items, enum)", err) } if err := validateSchemaShape(s, ""); err != nil { return artifactSchema{}, err @@ -262,13 +273,21 @@ func loadRequireArtifactSchemas(values []string) ([]loadedArtifactSchema, error) return out, nil } +const maxSchemaArtifactBytes = 5 * 1024 * 1024 + +type remoteArtifactReader func(ctx context.Context, target SSHTarget, workdir, remote string, maxBytes int) ([]byte, error) + func validateRemoteArtifactSchemas(ctx context.Context, target SSHTarget, workdir string, schemas []loadedArtifactSchema) ([]SchemaValidationResult, string, error) { + return validateArtifactSchemasWithReader(ctx, target, workdir, schemas, readRemoteArtifactBytes) +} + +func validateArtifactSchemasWithReader(ctx context.Context, target SSHTarget, workdir string, schemas []loadedArtifactSchema, read remoteArtifactReader) ([]SchemaValidationResult, string, error) { results := make([]SchemaValidationResult, 0, len(schemas)) var lines []string var firstFailure error for _, s := range schemas { result := SchemaValidationResult{Artifact: s.remote, Schema: s.schemaPath} - data, err := readRemoteArtifactBytes(ctx, target, workdir, s.remote) + data, err := read(ctx, target, workdir, s.remote, maxSchemaArtifactBytes) if err != nil { result.Valid = false result.Error = "fetch failed" @@ -301,14 +320,38 @@ func validateRemoteArtifactSchemas(ctx context.Context, target SSHTarget, workdi return results, strings.Join(lines, "\n"), firstFailure } -func readRemoteArtifactBytes(ctx context.Context, target SSHTarget, workdir, remote string) ([]byte, error) { - encoded, err := runSSHOutput(ctx, target, remoteDownloadBase64Command(target, workdir, remote)) +func readRemoteArtifactBytes(ctx context.Context, target SSHTarget, workdir, remote string, maxBytes int) ([]byte, error) { + encoded, err := runSSHOutput(ctx, target, remoteBoundedReadBase64Command(target, workdir, remote, maxBytes)) if err != nil { return nil, err } + return decodeBoundedBase64(encoded, maxBytes) +} + +func decodeBoundedBase64(encoded string, maxBytes int) ([]byte, error) { data, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(encoded), "")) if err != nil { return nil, fmt.Errorf("decode base64: %w", err) } + if len(data) > maxBytes { + return nil, fmt.Errorf("artifact exceeds the %d-byte validation limit", maxBytes) + } return data, nil } + +func remoteBoundedReadBase64Command(target SSHTarget, workdir, remotePath string, maxBytes int) string { + limit := maxBytes + 1 + if isWindowsNativeTarget(target) { + return powershellCommand(`$ErrorActionPreference = "Stop" +Set-Location -LiteralPath ` + psQuote(workdir) + ` +$path = ` + psQuote(remotePath) + ` +if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "artifact not found: $path" } +$stream = [System.IO.File]::OpenRead((Resolve-Path -LiteralPath $path).Path) +try { + $buffer = New-Object byte[] ` + fmt.Sprint(limit) + ` + $read = $stream.Read($buffer, 0, $buffer.Length) + [Convert]::ToBase64String($buffer, 0, $read) +} finally { $stream.Dispose() }`) + } + return fmt.Sprintf("cd %s && test -f %s && head -c %d %s | base64", shellQuote(workdir), shellQuote(remotePath), limit, shellQuote(remotePath)) +} From 1b8e5a32782c68302ba691b5eeaf7cd071261e2d Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:00:44 +0330 Subject: [PATCH 07/13] fix(internal/cli/run_artifact_schema_test.go): adding up more proper tests according to review response --- internal/cli/run_artifact_schema_test.go | 101 +++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go index 912821857..d27754b7a 100644 --- a/internal/cli/run_artifact_schema_test.go +++ b/internal/cli/run_artifact_schema_test.go @@ -1,6 +1,10 @@ package cli import ( + "bytes" + "context" + "encoding/base64" + "errors" "os" "path/filepath" "strings" @@ -219,6 +223,103 @@ func TestLoadRequireArtifactSchemas(t *testing.T) { }) } +func TestParseArtifactSchemaFailsClosedOnUnsupportedKeywords(t *testing.T) { + cases := []struct { + name string + schema string + }{ + {"pattern", `{"type":"string","pattern":"^x"}`}, + {"minimum", `{"type":"number","minimum":0}`}, + {"additionalProperties", `{"type":"object","additionalProperties":false}`}, + {"anyOf", `{"anyOf":[{"type":"string"}]}`}, + {"ref", `{"$ref":"#/definitions/x"}`}, + {"nested unsupported keyword", `{"type":"object","properties":{"x":{"type":"string","maxLength":3}}}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseArtifactSchema([]byte(tc.schema)); err == nil { + t.Fatalf("expected %s schema to be rejected fail-closed", tc.name) + } + }) + } +} + +func TestParseArtifactSchemaAcceptsAnnotationKeywords(t *testing.T) { + data := []byte(`{"$schema":"x","$id":"y","title":"t","description":"d","$comment":"c","examples":[1],"default":1,"deprecated":false,"type":"object","required":["a"]}`) + if _, err := parseArtifactSchema(data); err != nil { + t.Fatalf("annotation keywords should be accepted, got: %v", err) + } +} + +func TestDecodeBoundedBase64(t *testing.T) { + data, err := decodeBoundedBase64(base64.StdEncoding.EncodeToString([]byte("hello")), 1024) + if err != nil || string(data) != "hello" { + t.Fatalf("decode within limit: data=%q err=%v", data, err) + } + oversized := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("a"), 11)) + if _, err := decodeBoundedBase64(oversized, 10); err == nil { + t.Fatalf("expected oversized payload to be rejected") + } +} + +func TestRemoteBoundedReadBase64CommandBoundsBytes(t *testing.T) { + cmd := remoteBoundedReadBase64Command(SSHTarget{}, "/work", "out.json", 10) + if !strings.Contains(cmd, "head -c 11 ") { + t.Fatalf("expected bounded `head -c 11` in command, got: %s", cmd) + } +} + +func TestValidateArtifactSchemasWithReaderBehaviour(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}`)) + if err != nil { + t.Fatalf("schema parse: %v", err) + } + load := []loadedArtifactSchema{{remote: "out.json", schemaPath: "s.json", schema: schema}} + + t.Run("valid artifact passes with no failure", func(t *testing.T) { + reader := func(_ context.Context, _ SSHTarget, _, _ string, _ int) ([]byte, error) { + return []byte(`{"ok":true}`), nil + } + results, _, err := validateArtifactSchemasWithReader(context.Background(), SSHTarget{}, "/work", load, reader) + if err != nil { + t.Fatalf("expected no gate failure, got %v", err) + } + if len(results) != 1 || !results[0].Valid { + t.Fatalf("expected one valid result, got %+v", results) + } + }) + + t.Run("invalid content fails exit 7 with violations", func(t *testing.T) { + reader := func(_ context.Context, _ SSHTarget, _, _ string, _ int) ([]byte, error) { + return []byte(`{"ok":"nope"}`), nil + } + results, _, err := validateArtifactSchemasWithReader(context.Background(), SSHTarget{}, "/work", load, reader) + assertExitCode(t, err, 7) + if len(results) != 1 || results[0].Valid || len(results[0].Violations) == 0 { + t.Fatalf("expected one invalid result with violations, got %+v", results) + } + }) + + t.Run("fetch error fails exit 7", func(t *testing.T) { + reader := func(_ context.Context, _ SSHTarget, _, _ string, _ int) ([]byte, error) { + return nil, errors.New("connection refused") + } + results, _, err := validateArtifactSchemasWithReader(context.Background(), SSHTarget{}, "/work", load, reader) + assertExitCode(t, err, 7) + if len(results) != 1 || results[0].Error == "" { + t.Fatalf("expected one result recording the fetch error, got %+v", results) + } + }) + + t.Run("oversized artifact fails exit 7", func(t *testing.T) { + reader := func(_ context.Context, _ SSHTarget, _, _ string, maxBytes int) ([]byte, error) { + return decodeBoundedBase64(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("a"), maxBytes+1)), maxBytes) + } + _, _, err := validateArtifactSchemasWithReader(context.Background(), SSHTarget{}, "/work", load, reader) + assertExitCode(t, err, 7) + }) +} + func assertExitCode(t *testing.T, err error, want int) { t.Helper() if err == nil { From 492921236b66005abb9613838d62aeb27361f3d1 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:01:24 +0330 Subject: [PATCH 08/13] fix(docs/features/artifacts.md): updating up the docs accroding to latest changes --- docs/features/artifacts.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/features/artifacts.md b/docs/features/artifacts.md index ecb4efa06..6916c61bc 100644 --- a/docs/features/artifacts.md +++ b/docs/features/artifacts.md @@ -41,14 +41,20 @@ secrets, credentials, signed URLs, or unredacted customer rows. `--require-artifact-schema remote=schema.json` goes one step further than the existence guard: after command success it fetches the named JSON artifact from the lease and validates its content against a local schema file, failing the run -with `exit 7` when the artifact is missing, unparseable, or does not match. The -schema is a small dependency-free subset of JSON Schema (`type`, `required`, -`properties`, `items`, `enum`); unknown keywords are ignored, so a full JSON -Schema document works and richer keywords can be added later. A malformed schema -file fails fast at preflight with `exit 2`. Each result is recorded on the timing -report under `schemaValidations`. The flag is repeatable and, in this first -phase, is supported on SSH-backed providers only; delegated-run providers reject -it until they expose an explicit capability. +with `exit 7` when the artifact is missing, unparseable, oversized, or does not +match. The schema is a small dependency-free subset of JSON Schema (`type`, +`required`, `properties`, `items`, `enum`) and is **fail-closed**: a schema +containing any other validation keyword (`pattern`, `minimum`, +`additionalProperties`, `anyOf`, `$ref`, …) is rejected at preflight with +`exit 2`, so a passing gate always means the supplied constraints were actually +enforced — never silently skipped. Only annotation keywords (`$schema`, `$id`, +`title`, `description`, `$comment`, `examples`, `default`, `deprecated`) are +accepted and ignored. A malformed or unreadable schema file also fails fast at +preflight with `exit 2`. The fetched artifact is bounded (5 MiB); a larger +artifact fails the gate instead of being read into memory. Each result is +recorded on the timing report under `schemaValidations`. The flag is repeatable +and, in this first phase, is supported on SSH-backed providers only; +delegated-run providers reject it until they expose an explicit capability. Delegated providers reject run artifact collection until they grow an explicit bounded artifact capability. Archive-capable adapters may validate and collect From 248b5c125b928dd95e9770025f23dd7ba702f7c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 02:13:50 +0100 Subject: [PATCH 09/13] fix(cli): harden artifact schema validation Co-authored-by: Dwin Gharibi --- docs/features/artifacts.md | 3 +- internal/cli/run_artifact_schema.go | 305 ++++++++++++++++++++--- internal/cli/run_artifact_schema_test.go | 125 +++++++++- 3 files changed, 394 insertions(+), 39 deletions(-) diff --git a/docs/features/artifacts.md b/docs/features/artifacts.md index 6916c61bc..5fe679874 100644 --- a/docs/features/artifacts.md +++ b/docs/features/artifacts.md @@ -40,7 +40,8 @@ secrets, credentials, signed URLs, or unredacted customer rows. `--require-artifact-schema remote=schema.json` goes one step further than the existence guard: after command success it fetches the named JSON artifact from -the lease and validates its content against a local schema file, failing the run +the lease and validates its content against a local schema file. The remote +artifact must be an exact safe relative path, not a glob. Validation fails the run with `exit 7` when the artifact is missing, unparseable, oversized, or does not match. The schema is a small dependency-free subset of JSON Schema (`type`, `required`, `properties`, `items`, `enum`) and is **fail-closed**: a schema diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go index b83709ab1..9b5f4c0fe 100644 --- a/internal/cli/run_artifact_schema.go +++ b/internal/cli/run_artifact_schema.go @@ -6,27 +6,40 @@ import ( "encoding/base64" "encoding/json" "fmt" - "math" + "io" + "math/big" "os" - "reflect" + "path" "sort" + "strconv" "strings" ) type artifactSchema struct { - Type string `json:"type"` - Required []string `json:"required"` - Properties map[string]artifactSchema `json:"properties"` - Items *artifactSchema `json:"items"` - Enum []interface{} `json:"enum"` - AnnotationSchema json.RawMessage `json:"$schema,omitempty"` - AnnotationID json.RawMessage `json:"$id,omitempty"` - AnnotationComment json.RawMessage `json:"$comment,omitempty"` - AnnotationTitle json.RawMessage `json:"title,omitempty"` - AnnotationDescription json.RawMessage `json:"description,omitempty"` - AnnotationExamples json.RawMessage `json:"examples,omitempty"` - AnnotationDefault json.RawMessage `json:"default,omitempty"` - AnnotationDeprecated json.RawMessage `json:"deprecated,omitempty"` + Type string + Required []string + Properties map[string]artifactSchema + Items *artifactSchema + Enum []interface{} + hasType bool + hasEnum bool + enumKeys map[string]struct{} +} + +type artifactSchemaWire struct { + Type json.RawMessage `json:"type"` + Required json.RawMessage `json:"required"` + Properties json.RawMessage `json:"properties"` + Items json.RawMessage `json:"items"` + Enum json.RawMessage `json:"enum"` + AnnotationSchema json.RawMessage `json:"$schema"` + AnnotationID json.RawMessage `json:"$id"` + AnnotationComment json.RawMessage `json:"$comment"` + AnnotationTitle json.RawMessage `json:"title"` + AnnotationDescription json.RawMessage `json:"description"` + AnnotationExamples json.RawMessage `json:"examples"` + AnnotationDefault json.RawMessage `json:"default"` + AnnotationDeprecated json.RawMessage `json:"deprecated"` } type schemaViolation struct { @@ -68,16 +81,121 @@ func parseArtifactSchema(data []byte) (artifactSchema, error) { if err := decoder.Decode(&s); err != nil { return artifactSchema{}, fmt.Errorf("%w (supported keywords: type, required, properties, items, enum)", err) } + if err := requireJSONDecoderEOF(decoder); err != nil { + return artifactSchema{}, fmt.Errorf("%w (schema must contain exactly one JSON value)", err) + } if err := validateSchemaShape(s, ""); err != nil { return artifactSchema{}, err } return s, nil } +func (s *artifactSchema) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || trimmed[0] != '{' { + return fmt.Errorf("schema must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var wire artifactSchemaWire + if err := decoder.Decode(&wire); err != nil { + return err + } + if err := requireJSONDecoderEOF(decoder); err != nil { + return err + } + + if wire.Type != nil { + s.hasType = true + if err := decodeSchemaKeyword("type", wire.Type, &s.Type, false); err != nil { + return err + } + } + if wire.Required != nil { + if err := decodeSchemaKeyword("required", wire.Required, &s.Required, false); err != nil { + return err + } + } + if wire.Properties != nil { + var properties map[string]json.RawMessage + if err := decodeSchemaKeyword("properties", wire.Properties, &properties, false); err != nil { + return err + } + s.Properties = make(map[string]artifactSchema, len(properties)) + for key, raw := range properties { + var child artifactSchema + if err := decodeSchemaKeyword("properties."+key, raw, &child, false); err != nil { + return err + } + s.Properties[key] = child + } + } + if wire.Items != nil { + var items artifactSchema + if err := decodeSchemaKeyword("items", wire.Items, &items, false); err != nil { + return err + } + s.Items = &items + } + if wire.Enum != nil { + s.hasEnum = true + if err := decodeSchemaKeyword("enum", wire.Enum, &s.Enum, true); err != nil { + return err + } + s.enumKeys = make(map[string]struct{}, len(s.Enum)) + for _, value := range s.Enum { + key := schemaJSONKey(value) + if _, exists := s.enumKeys[key]; exists { + return fmt.Errorf("schema keyword %q must contain unique values", "enum") + } + s.enumKeys[key] = struct{}{} + } + } + return nil +} + +func decodeSchemaKeyword(keyword string, raw json.RawMessage, dst interface{}, useNumber bool) error { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return fmt.Errorf("schema keyword %q must not be null", keyword) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + if useNumber { + decoder.UseNumber() + } + if err := decoder.Decode(dst); err != nil { + return fmt.Errorf("invalid schema keyword %q: %w", keyword, err) + } + if err := requireJSONDecoderEOF(decoder); err != nil { + return fmt.Errorf("invalid schema keyword %q: %w", keyword, err) + } + return nil +} + +func requireJSONDecoderEOF(decoder *json.Decoder) error { + var trailing interface{} + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing JSON value") + } + return fmt.Errorf("unexpected trailing content: %w", err) + } + return nil +} + func validateSchemaShape(s artifactSchema, path string) error { - if s.Type != "" && !knownSchemaTypes[s.Type] { + if s.hasType && !knownSchemaTypes[s.Type] { return fmt.Errorf("unknown type %q at %s", s.Type, schemaPathOrRoot(path)) } + seenRequired := make(map[string]bool, len(s.Required)) + for _, key := range s.Required { + if seenRequired[key] { + return fmt.Errorf("duplicate required property %q at %s", key, schemaPathOrRoot(path)) + } + seenRequired[key] = true + } + if s.hasEnum && len(s.Enum) == 0 { + return fmt.Errorf("enum must contain at least one value at %s", schemaPathOrRoot(path)) + } for _, key := range sortedSchemaKeys(s.Properties) { if err := validateSchemaShape(s.Properties[key], schemaJoinPath(path, key)); err != nil { return err @@ -93,7 +211,12 @@ func validateSchemaShape(s artifactSchema, path string) error { func validateJSONAgainstSchema(doc []byte, schema artifactSchema) []schemaViolation { var value interface{} - if err := json.Unmarshal(doc, &value); err != nil { + decoder := json.NewDecoder(bytes.NewReader(doc)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return []schemaViolation{{Keyword: "json", Message: fmt.Sprintf("artifact is not valid JSON: %v", err)}} + } + if err := requireJSONDecoderEOF(decoder); err != nil { return []schemaViolation{{Keyword: "json", Message: fmt.Sprintf("artifact is not valid JSON: %v", err)}} } var out []schemaViolation @@ -102,7 +225,7 @@ func validateJSONAgainstSchema(doc []byte, schema artifactSchema) []schemaViolat } func validateSchemaValue(value interface{}, schema artifactSchema, path string, out *[]schemaViolation) { - if schema.Type != "" && !schemaTypeMatches(schema.Type, value) { + if schema.hasType && !schemaTypeMatches(schema.Type, value) { *out = append(*out, schemaViolation{ Path: path, Keyword: "type", @@ -110,11 +233,11 @@ func validateSchemaValue(value interface{}, schema artifactSchema, path string, }) return } - if len(schema.Enum) > 0 && !schemaEnumContains(schema.Enum, value) { + if schema.hasEnum && !schemaEnumContains(schema.enumKeys, value) { *out = append(*out, schemaViolation{ Path: path, Keyword: "enum", - Message: fmt.Sprintf("value %s is not one of the allowed values", schemaCompactJSON(value)), + Message: "value is not one of the allowed values", }) return } @@ -160,11 +283,15 @@ func schemaTypeMatches(t string, value interface{}) bool { case "null": return value == nil case "number": - _, ok := value.(float64) + _, ok := value.(json.Number) return ok case "integer": - f, ok := value.(float64) - return ok && f == math.Trunc(f) + n, ok := value.(json.Number) + if !ok { + return false + } + canonical, ok := canonicalizeJSONNumber(n) + return ok && canonical.isInteger() default: return true } @@ -180,7 +307,7 @@ func schemaTypeName(value interface{}) string { return "string" case bool: return "boolean" - case float64: + case json.Number: return "number" case nil: return "null" @@ -189,21 +316,110 @@ func schemaTypeName(value interface{}) string { } } -func schemaEnumContains(enum []interface{}, value interface{}) bool { - for _, candidate := range enum { - if reflect.DeepEqual(candidate, value) { - return true +func schemaEnumContains(enumKeys map[string]struct{}, value interface{}) bool { + _, ok := enumKeys[schemaJSONKey(value)] + return ok +} + +func schemaJSONKey(value interface{}) string { + var out strings.Builder + appendSchemaJSONKey(&out, value) + return out.String() +} + +func appendSchemaJSONKey(out *strings.Builder, value interface{}) { + switch typed := value.(type) { + case nil: + out.WriteByte('z') + case bool: + if typed { + out.WriteString("b1") + } else { + out.WriteString("b0") + } + case string: + out.WriteByte('s') + out.WriteString(strconv.Quote(typed)) + case json.Number: + canonical, ok := canonicalizeJSONNumber(typed) + if !ok { + out.WriteString("invalid-number:") + out.WriteString(typed.String()) + return + } + out.WriteByte('n') + if canonical.negative { + out.WriteByte('-') + } + out.WriteString(canonical.digits) + out.WriteByte('e') + out.WriteString(canonical.exponent.String()) + out.WriteByte(';') + case []interface{}: + out.WriteByte('[') + for _, item := range typed { + appendSchemaJSONKey(out, item) + out.WriteByte(',') } + out.WriteByte(']') + case map[string]interface{}: + out.WriteByte('{') + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + out.WriteString(strconv.Quote(key)) + out.WriteByte(':') + appendSchemaJSONKey(out, typed[key]) + out.WriteByte(',') + } + out.WriteByte('}') } - return false } -func schemaCompactJSON(value interface{}) string { - b, err := json.Marshal(value) - if err != nil { - return fmt.Sprintf("%v", value) +type canonicalJSONNumber struct { + negative bool + digits string + exponent big.Int +} + +func canonicalizeJSONNumber(number json.Number) (canonicalJSONNumber, bool) { + text := number.String() + negative := strings.HasPrefix(text, "-") + if negative { + text = text[1:] + } + + exponent := new(big.Int) + if index := strings.IndexAny(text, "eE"); index >= 0 { + parsed, ok := new(big.Int).SetString(text[index+1:], 10) + if !ok { + return canonicalJSONNumber{}, false + } + exponent.Set(parsed) + text = text[:index] + } + + fractionDigits := 0 + if index := strings.IndexByte(text, '.'); index >= 0 { + fractionDigits = len(text) - index - 1 + text = text[:index] + text[index+1:] + } + text = strings.TrimLeft(text, "0") + if text == "" { + return canonicalJSONNumber{digits: "0"}, true } - return string(b) + + exponent.Sub(exponent, new(big.Int).SetInt64(int64(fractionDigits))) + trimmed := strings.TrimRight(text, "0") + exponent.Add(exponent, new(big.Int).SetInt64(int64(len(text)-len(trimmed)))) + return canonicalJSONNumber{negative: negative, digits: trimmed, exponent: *exponent}, true +} + +func (n canonicalJSONNumber) isInteger() bool { + return n.digits == "0" || n.exponent.Sign() >= 0 } func schemaJoinPath(base, key string) string { @@ -242,12 +458,22 @@ func parseRequireArtifactSchemaSpec(value string) (remote, schemaPath string, er if !ok || remote == "" || schemaPath == "" { return "", "", exit(2, "--require-artifact-schema expects remote=schema.json") } - if err := validateRequiredRunArtifactGlobs([]string{remote}); err != nil { + remote, err = normalizeArtifactSchemaRemotePath(remote) + if err != nil { return "", "", err } return remote, schemaPath, nil } +func normalizeArtifactSchemaRemotePath(remote string) (string, error) { + remote = strings.TrimSpace(remote) + clean := path.Clean(remote) + if remote == "" || clean == "." || !safeArtifactGlob(remote) || strings.ContainsAny(remote, "*?:\\[]") || strings.HasPrefix(remote, "/") { + return "", exit(2, "--require-artifact-schema requires a safe relative artifact path: %s", remote) + } + return clean, nil +} + func loadRequireArtifactSchemas(values []string) ([]loadedArtifactSchema, error) { out := make([]loadedArtifactSchema, 0, len(values)) seen := make(map[string]bool, len(values)) @@ -349,8 +575,13 @@ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "artifact not fo $stream = [System.IO.File]::OpenRead((Resolve-Path -LiteralPath $path).Path) try { $buffer = New-Object byte[] ` + fmt.Sprint(limit) + ` - $read = $stream.Read($buffer, 0, $buffer.Length) - [Convert]::ToBase64String($buffer, 0, $read) + $offset = 0 + while ($offset -lt $buffer.Length) { + $read = $stream.Read($buffer, $offset, $buffer.Length - $offset) + if ($read -eq 0) { break } + $offset += $read + } + [Convert]::ToBase64String($buffer, 0, $offset) } finally { $stream.Dispose() }`) } return fmt.Sprintf("cd %s && test -f %s && head -c %d %s | base64", shellQuote(workdir), shellQuote(remotePath), limit, shellQuote(remotePath)) diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go index d27754b7a..5a567069f 100644 --- a/internal/cli/run_artifact_schema_test.go +++ b/internal/cli/run_artifact_schema_test.go @@ -12,7 +12,7 @@ import ( ) func TestParseArtifactSchema(t *testing.T) { - t.Run("valid nested schema with ignored unknown keywords", func(t *testing.T) { + t.Run("valid nested schema with annotations", func(t *testing.T) { data := []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "ignored", @@ -42,6 +42,17 @@ func TestParseArtifactSchema(t *testing.T) { } }) + t.Run("trailing content is rejected", func(t *testing.T) { + for _, data := range []string{ + `{"type":"object"}{"type":"string"}`, + `{"type":"object"} trailing`, + } { + if _, err := parseArtifactSchema([]byte(data)); err == nil { + t.Fatalf("expected trailing schema content to be rejected: %q", data) + } + } + }) + t.Run("unknown type keyword is rejected", func(t *testing.T) { _, err := parseArtifactSchema([]byte(`{"type": "timestamp"}`)) if err == nil || !strings.Contains(err.Error(), "unknown type") { @@ -57,6 +68,34 @@ func TestParseArtifactSchema(t *testing.T) { }) } +func TestParseArtifactSchemaRejectsInvalidKeywordShapes(t *testing.T) { + cases := []struct { + name string + schema string + }{ + {"empty type", `{"type":""}`}, + {"null type", `{"type":null}`}, + {"null root", `null`}, + {"array root", `[]`}, + {"null required", `{"required":null}`}, + {"duplicate required", `{"required":["x","x"]}`}, + {"null properties", `{"properties":null}`}, + {"null property schema", `{"properties":{"x":null}}`}, + {"null items", `{"items":null}`}, + {"empty enum", `{"enum":[]}`}, + {"null enum", `{"enum":null}`}, + {"duplicate numeric enum", `{"enum":[1,1.0]}`}, + {"duplicate object enum", `{"enum":[{"x":1,"y":2},{"y":2.0,"x":1.0}]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseArtifactSchema([]byte(tc.schema)); err == nil { + t.Fatalf("expected invalid schema keyword shape to be rejected: %s", tc.schema) + } + }) + } +} + func TestValidateJSONAgainstSchema(t *testing.T) { schema, err := parseArtifactSchema([]byte(`{ "type": "object", @@ -157,6 +196,67 @@ func TestValidateJSONAgainstSchemaTypeMismatchDoesNotCascade(t *testing.T) { } } +func TestValidateJSONAgainstSchemaPreservesExactNumbers(t *testing.T) { + t.Run("large integers do not collapse in enum", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"enum":[9007199254740992]}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`9007199254740993`), schema) + if len(violations) != 1 || violations[0].Keyword != "enum" { + t.Fatalf("expected exact enum mismatch, got %v", violations) + } + }) + + t.Run("equivalent JSON number spellings compare equal", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"enum":[1]}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`1.0`), schema); len(violations) != 0 { + t.Fatalf("expected numerically equal enum value, got %v", violations) + } + }) + + t.Run("fraction beyond float64 precision is not integer", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"integer"}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`1.0000000000000001`), schema) + if len(violations) != 1 || violations[0].Keyword != "type" { + t.Fatalf("expected exact integer mismatch, got %v", violations) + } + }) + + t.Run("large exponent remains exact without expansion", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"integer","enum":[1e1000001]}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`10e1000000`), schema); len(violations) != 0 { + t.Fatalf("expected equivalent large-exponent integer to pass, got %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`1.1e-1000001`), schema); len(violations) == 0 { + t.Fatalf("expected distinct large-exponent fraction to fail") + } + }) +} + +func TestValidateJSONAgainstSchemaEnumDiagnosticDoesNotIncludeValue(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"enum":["allowed"]}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`"sensitive-value"`), schema) + if len(violations) != 1 { + t.Fatalf("expected one enum violation, got %v", violations) + } + if strings.Contains(violations[0].String(), "sensitive-value") { + t.Fatalf("enum diagnostic leaked rejected value: %s", violations[0]) + } +} + func TestParseRequireArtifactSchemaSpec(t *testing.T) { t.Run("valid spec", func(t *testing.T) { remote, schema, err := parseRequireArtifactSchemaSpec("reports/out.json=schema.json") @@ -184,6 +284,14 @@ func TestParseRequireArtifactSchemaSpec(t *testing.T) { t.Fatalf("expected error for parent-escaping remote path") } }) + + t.Run("glob and Windows absolute paths are rejected", func(t *testing.T) { + for _, spec := range []string{"reports/*.json=schema.json", "reports/out?.json=schema.json", "reports/[0-9].json=schema.json", "C:/secrets.json=schema.json"} { + if _, _, err := parseRequireArtifactSchemaSpec(spec); err == nil { + t.Fatalf("expected exact relative path requirement for %q", spec) + } + } + }) } func TestLoadRequireArtifactSchemas(t *testing.T) { @@ -269,6 +377,21 @@ func TestRemoteBoundedReadBase64CommandBoundsBytes(t *testing.T) { } } +func TestRemoteBoundedReadBase64CommandWindowsReadsToEOFOrLimit(t *testing.T) { + target := SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeNormal} + cmd := decodePowerShellCommand(t, remoteBoundedReadBase64Command(target, `C:\work`, "out.json", 10)) + for _, want := range []string{ + "while ($offset -lt $buffer.Length)", + "$stream.Read($buffer, $offset, $buffer.Length - $offset)", + "if ($read -eq 0) { break }", + "ToBase64String($buffer, 0, $offset)", + } { + if !strings.Contains(cmd, want) { + t.Fatalf("expected %q in bounded Windows command, got: %s", want, cmd) + } + } +} + func TestValidateArtifactSchemasWithReaderBehaviour(t *testing.T) { schema, err := parseArtifactSchema([]byte(`{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}`)) if err != nil { From 3ff3ca8717c3100711ad90a94b87285379fa3234 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 02:34:36 +0100 Subject: [PATCH 10/13] fix(cli): bound schema validation failures Co-authored-by: Dwin Gharibi --- internal/cli/run_artifact_schema.go | 119 +++++++++++++++++++++-- internal/cli/run_artifact_schema_test.go | 24 ++++- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go index 9b5f4c0fe..6c55777e1 100644 --- a/internal/cli/run_artifact_schema.go +++ b/internal/cli/run_artifact_schema.go @@ -48,6 +48,38 @@ type schemaViolation struct { Message string } +const maxSchemaViolations = 100 + +type schemaViolationAccumulator struct { + violations []schemaViolation + truncated bool +} + +func (a *schemaViolationAccumulator) add(violation schemaViolation) { + if a.truncated { + return + } + if len(a.violations) >= maxSchemaViolations { + a.truncated = true + return + } + a.violations = append(a.violations, violation) +} + +func (a *schemaViolationAccumulator) full() bool { + return a.truncated +} + +func (a *schemaViolationAccumulator) result() []schemaViolation { + if a.truncated { + a.violations = append(a.violations, schemaViolation{ + Keyword: "truncated", + Message: fmt.Sprintf("additional violations omitted after the first %d", maxSchemaViolations), + }) + } + return a.violations +} + func (v schemaViolation) String() string { loc := v.Path if loc == "" { @@ -75,6 +107,9 @@ var knownSchemaTypes = map[string]bool{ } func parseArtifactSchema(data []byte) (artifactSchema, error) { + if err := rejectDuplicateJSONNames(data); err != nil { + return artifactSchema{}, err + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var s artifactSchema @@ -154,6 +189,57 @@ func (s *artifactSchema) UnmarshalJSON(data []byte) error { return nil } +func rejectDuplicateJSONNames(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := scanJSONValue(decoder); err != nil { + return err + } + return requireJSONDecoderEOF(decoder) +} + +func scanJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, isDelim := token.(json.Delim) + if !isDelim { + return nil + } + switch delim { + case '{': + seen := make(map[string]bool) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("schema object contains a non-string key") + } + if seen[key] { + return fmt.Errorf("schema contains duplicate object name %q", key) + } + seen[key] = true + if err := scanJSONValue(decoder); err != nil { + return err + } + } + case '[': + for decoder.More() { + if err := scanJSONValue(decoder); err != nil { + return err + } + } + } + if _, err := decoder.Token(); err != nil { + return err + } + return nil +} + func decodeSchemaKeyword(keyword string, raw json.RawMessage, dst interface{}, useNumber bool) error { if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return fmt.Errorf("schema keyword %q must not be null", keyword) @@ -219,14 +305,14 @@ func validateJSONAgainstSchema(doc []byte, schema artifactSchema) []schemaViolat if err := requireJSONDecoderEOF(decoder); err != nil { return []schemaViolation{{Keyword: "json", Message: fmt.Sprintf("artifact is not valid JSON: %v", err)}} } - var out []schemaViolation + var out schemaViolationAccumulator validateSchemaValue(value, schema, "", &out) - return out + return out.result() } -func validateSchemaValue(value interface{}, schema artifactSchema, path string, out *[]schemaViolation) { +func validateSchemaValue(value interface{}, schema artifactSchema, path string, out *schemaViolationAccumulator) { if schema.hasType && !schemaTypeMatches(schema.Type, value) { - *out = append(*out, schemaViolation{ + out.add(schemaViolation{ Path: path, Keyword: "type", Message: fmt.Sprintf("expected type %s, got %s", schema.Type, schemaTypeName(value)), @@ -234,7 +320,7 @@ func validateSchemaValue(value interface{}, schema artifactSchema, path string, return } if schema.hasEnum && !schemaEnumContains(schema.enumKeys, value) { - *out = append(*out, schemaViolation{ + out.add(schemaViolation{ Path: path, Keyword: "enum", Message: "value is not one of the allowed values", @@ -244,8 +330,11 @@ func validateSchemaValue(value interface{}, schema artifactSchema, path string, switch v := value.(type) { case map[string]interface{}: for _, req := range schema.Required { + if out.full() { + return + } if _, ok := v[req]; !ok { - *out = append(*out, schemaViolation{ + out.add(schemaViolation{ Path: schemaJoinPath(path, req), Keyword: "required", Message: fmt.Sprintf("missing required property %q", req), @@ -253,6 +342,9 @@ func validateSchemaValue(value interface{}, schema artifactSchema, path string, } } for _, key := range sortedSchemaKeys(schema.Properties) { + if out.full() { + return + } if child, ok := v[key]; ok { validateSchemaValue(child, schema.Properties[key], schemaJoinPath(path, key), out) } @@ -260,6 +352,9 @@ func validateSchemaValue(value interface{}, schema artifactSchema, path string, case []interface{}: if schema.Items != nil { for i, item := range v { + if out.full() { + return + } validateSchemaValue(item, *schema.Items, fmt.Sprintf("%s[%d]", path, i), out) } } @@ -535,17 +630,25 @@ func validateArtifactSchemasWithReader(ctx context.Context, target SSHTarget, wo result.Violations = append(result.Violations, v.String()) } results = append(results, result) - lines = append(lines, fmt.Sprintf("schema %s: failed %d check(s) against %s:", s.remote, len(violations), s.schemaPath)) + violationSummary := schemaViolationSummary(violations) + lines = append(lines, fmt.Sprintf("schema %s: failed %s against %s:", s.remote, violationSummary, s.schemaPath)) for _, v := range violations { lines = append(lines, " - "+v.String()) } if firstFailure == nil { - firstFailure = exit(7, "artifact schema validation failed: %s (%d violation(s))", s.remote, len(violations)) + firstFailure = exit(7, "artifact schema validation failed: %s (%s)", s.remote, violationSummary) } } return results, strings.Join(lines, "\n"), firstFailure } +func schemaViolationSummary(violations []schemaViolation) string { + if len(violations) > 0 && violations[len(violations)-1].Keyword == "truncated" { + return fmt.Sprintf("at least %d checks", maxSchemaViolations+1) + } + return fmt.Sprintf("%d check(s)", len(violations)) +} + func readRemoteArtifactBytes(ctx context.Context, target SSHTarget, workdir, remote string, maxBytes int) ([]byte, error) { encoded, err := runSSHOutput(ctx, target, remoteBoundedReadBase64Command(target, workdir, remote, maxBytes)) if err != nil { diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go index 5a567069f..a5a29e4c0 100644 --- a/internal/cli/run_artifact_schema_test.go +++ b/internal/cli/run_artifact_schema_test.go @@ -86,6 +86,9 @@ func TestParseArtifactSchemaRejectsInvalidKeywordShapes(t *testing.T) { {"null enum", `{"enum":null}`}, {"duplicate numeric enum", `{"enum":[1,1.0]}`}, {"duplicate object enum", `{"enum":[{"x":1,"y":2},{"y":2.0,"x":1.0}]}`}, + {"duplicate root keyword", `{"type":"object","type":"string"}`}, + {"duplicate nested keyword", `{"properties":{"x":{"required":["a"],"required":[]}}}`}, + {"duplicate property schema", `{"properties":{"x":{"type":"string"},"x":{"type":"number"}}}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -257,6 +260,25 @@ func TestValidateJSONAgainstSchemaEnumDiagnosticDoesNotIncludeValue(t *testing.T } } +func TestValidateJSONAgainstSchemaBoundsViolations(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","items":{"type":"string"}}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := "[" + strings.Repeat("0,", maxSchemaViolations+20) + "0]" + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != maxSchemaViolations+1 { + t.Fatalf("violations=%d, want %d retained plus truncation marker", len(violations), maxSchemaViolations) + } + last := violations[len(violations)-1] + if last.Keyword != "truncated" || !strings.Contains(last.Message, "additional violations omitted") { + t.Fatalf("missing truncation marker: %v", last) + } + if got := schemaViolationSummary(violations); got != "at least 101 checks" { + t.Fatalf("summary=%q, want bounded count", got) + } +} + func TestParseRequireArtifactSchemaSpec(t *testing.T) { t.Run("valid spec", func(t *testing.T) { remote, schema, err := parseRequireArtifactSchemaSpec("reports/out.json=schema.json") @@ -286,7 +308,7 @@ func TestParseRequireArtifactSchemaSpec(t *testing.T) { }) t.Run("glob and Windows absolute paths are rejected", func(t *testing.T) { - for _, spec := range []string{"reports/*.json=schema.json", "reports/out?.json=schema.json", "reports/[0-9].json=schema.json", "C:/secrets.json=schema.json"} { + for _, spec := range []string{"reports/*.json=schema.json", "reports/out?.json=schema.json", "reports/[0-9].json=schema.json", "C:/secrets.json=schema.json", "-report.json=schema.json"} { if _, _, err := parseRequireArtifactSchemaSpec(spec); err == nil { t.Fatalf("expected exact relative path requirement for %q", spec) } From d04a148cb46ef6a708582620befb24fd25af73a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 04:30:49 +0100 Subject: [PATCH 11/13] fix(cli): reject schema aliases and invalid UTF-8 --- internal/cli/run_artifact_schema.go | 32 ++++++++++++++++++++++++ internal/cli/run_artifact_schema_test.go | 21 ++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go index 6c55777e1..81eb87adc 100644 --- a/internal/cli/run_artifact_schema.go +++ b/internal/cli/run_artifact_schema.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "unicode/utf8" ) type artifactSchema struct { @@ -106,7 +107,26 @@ var knownSchemaTypes = map[string]bool{ "null": true, } +var knownArtifactSchemaKeywords = map[string]bool{ + "type": true, + "required": true, + "properties": true, + "items": true, + "enum": true, + "$schema": true, + "$id": true, + "$comment": true, + "title": true, + "description": true, + "examples": true, + "default": true, + "deprecated": true, +} + func parseArtifactSchema(data []byte) (artifactSchema, error) { + if !utf8.Valid(data) { + return artifactSchema{}, fmt.Errorf("schema is not valid UTF-8") + } if err := rejectDuplicateJSONNames(data); err != nil { return artifactSchema{}, err } @@ -130,6 +150,15 @@ func (s *artifactSchema) UnmarshalJSON(data []byte) error { if len(trimmed) == 0 || trimmed[0] != '{' { return fmt.Errorf("schema must be a JSON object") } + var keywords map[string]json.RawMessage + if err := json.Unmarshal(data, &keywords); err != nil { + return err + } + for keyword := range keywords { + if !knownArtifactSchemaKeywords[keyword] { + return fmt.Errorf("unsupported schema keyword %q", keyword) + } + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var wire artifactSchemaWire @@ -296,6 +325,9 @@ func validateSchemaShape(s artifactSchema, path string) error { } func validateJSONAgainstSchema(doc []byte, schema artifactSchema) []schemaViolation { + if !utf8.Valid(doc) { + return []schemaViolation{{Keyword: "json", Message: "artifact is not valid UTF-8"}} + } var value interface{} decoder := json.NewDecoder(bytes.NewReader(doc)) decoder.UseNumber() diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go index a5a29e4c0..26f47bb6d 100644 --- a/internal/cli/run_artifact_schema_test.go +++ b/internal/cli/run_artifact_schema_test.go @@ -89,6 +89,7 @@ func TestParseArtifactSchemaRejectsInvalidKeywordShapes(t *testing.T) { {"duplicate root keyword", `{"type":"object","type":"string"}`}, {"duplicate nested keyword", `{"properties":{"x":{"required":["a"],"required":[]}}}`}, {"duplicate property schema", `{"properties":{"x":{"type":"string"},"x":{"type":"number"}}}`}, + {"mis-cased required cannot override required", `{"type":"object","required":["proof"],"REQUIRED":[]}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -326,6 +327,10 @@ func TestLoadRequireArtifactSchemas(t *testing.T) { if err := os.WriteFile(bad, []byte(`{"type":`), 0o600); err != nil { t.Fatalf("write schema: %v", err) } + invalidUTF8 := filepath.Join(dir, "invalid-utf8.schema.json") + if err := os.WriteFile(invalidUTF8, []byte{'{', '"', 't', 'i', 't', 'l', 'e', '"', ':', '"', 0xff, '"', '}'}, 0o600); err != nil { + t.Fatalf("write invalid UTF-8 schema: %v", err) + } t.Run("loads valid schema", func(t *testing.T) { loaded, err := loadRequireArtifactSchemas([]string{"out.json=" + good}) @@ -347,6 +352,11 @@ func TestLoadRequireArtifactSchemas(t *testing.T) { assertExitCode(t, err, 2) }) + t.Run("invalid UTF-8 schema file is exit 2", func(t *testing.T) { + _, err := loadRequireArtifactSchemas([]string{"out.json=" + invalidUTF8}) + assertExitCode(t, err, 2) + }) + t.Run("duplicate remote is exit 2", func(t *testing.T) { _, err := loadRequireArtifactSchemas([]string{"out.json=" + good, "out.json=" + good}) assertExitCode(t, err, 2) @@ -445,6 +455,17 @@ func TestValidateArtifactSchemasWithReaderBehaviour(t *testing.T) { } }) + t.Run("invalid UTF-8 artifact fails exit 7", func(t *testing.T) { + reader := func(_ context.Context, _ SSHTarget, _, _ string, _ int) ([]byte, error) { + return []byte{'{', '"', 'o', 'k', '"', ':', 't', 'r', 'u', 'e', ',', '"', 'n', 'o', 't', 'e', '"', ':', '"', 0xff, '"', '}'}, nil + } + results, _, err := validateArtifactSchemasWithReader(context.Background(), SSHTarget{}, "/work", load, reader) + assertExitCode(t, err, 7) + if len(results) != 1 || results[0].Valid || len(results[0].Violations) == 0 { + t.Fatalf("expected invalid UTF-8 artifact violation, got %+v", results) + } + }) + t.Run("fetch error fails exit 7", func(t *testing.T) { reader := func(_ context.Context, _ SSHTarget, _, _ string, _ int) ([]byte, error) { return nil, errors.New("connection refused") From 66eed25482c37bede92842cb4fa2777febab0e14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 04:01:58 -0700 Subject: [PATCH 12/13] feat(cli): support standard artifact JSON Schemas Co-authored-by: Dwin Gharibi --- CHANGELOG.md | 1 + docs/features/artifacts.md | 54 +- go.mod | 5 + go.sum | 4 + internal/cli/run_artifact_schema.go | 1853 +++++++++++++++++----- internal/cli/run_artifact_schema_test.go | 913 ++++++++++- 6 files changed, 2391 insertions(+), 439 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 686d7e2fe..f7fb0d4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added bounded JSON Schema validation for required run artifacts across supported standard drafts, with local references and redacted failure diagnostics. Thanks @dwin-gharibi. - Added a Herdr plugin for Crabbox lease controls and repository workflows, with workspace-aware actions and managed panes. Thanks @zozo123. - Added a single `open --editor=` lease handoff for external editors, starting with Zed Remote Projects and preserving lease activity while the editor is connected. Thanks @zozo123. - Added a searchable, filterable Features capability explorer with responsive light and dark layouts, deep-linked state, and browser interaction proof. Thanks @zozo123. diff --git a/docs/features/artifacts.md b/docs/features/artifacts.md index 5fe679874..5424c6bb6 100644 --- a/docs/features/artifacts.md +++ b/docs/features/artifacts.md @@ -43,18 +43,48 @@ existence guard: after command success it fetches the named JSON artifact from the lease and validates its content against a local schema file. The remote artifact must be an exact safe relative path, not a glob. Validation fails the run with `exit 7` when the artifact is missing, unparseable, oversized, or does not -match. The schema is a small dependency-free subset of JSON Schema (`type`, -`required`, `properties`, `items`, `enum`) and is **fail-closed**: a schema -containing any other validation keyword (`pattern`, `minimum`, -`additionalProperties`, `anyOf`, `$ref`, …) is rejected at preflight with -`exit 2`, so a passing gate always means the supplied constraints were actually -enforced — never silently skipped. Only annotation keywords (`$schema`, `$id`, -`title`, `description`, `$comment`, `examples`, `default`, `deprecated`) are -accepted and ignored. A malformed or unreadable schema file also fails fast at -preflight with `exit 2`. The fetched artifact is bounded (5 MiB); a larger -artifact fails the gate instead of being read into memory. Each result is -recorded on the timing report under `schemaValidations`. The flag is repeatable -and, in this first phase, is supported on SSH-backed providers only; +match. Schemas use standard JSON Schema: draft 2020-12 when `$schema` is absent, +or draft 4, 6, 7, 2019-09, or 2020-12 when declared. Standard constraints such +as `pattern`, `minimum`, `additionalProperties`, composition keywords, and local +static acyclic `$ref`/`$defs` references are enforced. Reference cycles, +external schema references, and +runtime-rebound `$dynamicRef`/`$recursiveRef` references are rejected at +preflight so validation never performs an implicit fetch or escapes the static +work bound. +Schema files are bounded to 1 MiB, 4,096 JSON values, and 128 bytes per object +name; malformed, unreadable, +oversized, or invalid schemas fail fast at preflight with `exit 2`. A schema may +contain up to 2,048 subschemas across the raw and referenced compiled graph; +cardinality constraints may be at most +2,147,483,647. Schema resource IDs, reference keywords, and resolved URLs are +capped at 2 KiB each and 1 MiB in aggregate. The fetched artifact is bounded to 5 MiB; a larger artifact fails +the gate instead of being read into memory. Schemas and artifacts are capped at +64 levels of JSON nesting. JSON artifacts are also capped at 100,000 values, and +the product of artifact values and expanded compiled-schema validation weight +(including reference fanout, subschemas, required fields, dependencies, and +`uniqueItems` equality candidates) is capped at 100,000 work units. Repeated +schema work over artifact string values and object names is separately capped at +16 MiB of byte-work. JSON Schema regular expressions use a fail-closed, +linear-time ECMA-262 subset with translated dot, anchor, whitespace, control, +and Unicode-escape semantics; backreferences, lookarounds, inline options, +Unicode property escapes, and empty character classes are rejected. Sources are +capped at 64 KiB, compiled +programs at 100,000 work units each, and schema-wide compilation at 1,000,000 +work units. The asserted `regex` format in drafts 4, 6, and 7 is rejected because +it would compile artifact-controlled expressions during validation; modern +format-annotation behavior remains available. Required/dependency names and +equality-candidate strings are charged by byte length. `uniqueItems` also charges worst-case structural, string, and numeric +comparison work for the largest artifact array. Numeric magnitude and +big-rational constraint work is capped at 16 MiB of numeric work, including +schema compilation. JSON numbers are capped at 1,024 characters and an absolute +exponent of 10,000. These limits bound compilation and validation before either +can build unbounded work. Failure diagnostics expose only JSON Pointer locations +and schema constraint locations, not rejected values; the empty root pointer is +displayed as `(root)`. Locations stop at 1 KiB, +the complete diagnostic set stops at 32 KiB, and at most 100 violations are +retained; control, line/paragraph separator, and bidirectional formatting characters are escaped. Each result is recorded on the timing report under +`schemaValidations`. The flag +is repeatable and, in this first phase, is supported on SSH-backed providers only; delegated-run providers reject it until they expose an explicit capability. Delegated providers reject run artifact collection until they grow an explicit diff --git a/go.mod b/go.mod index 4b0805340..c42d6c1db 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/containernetworking/cni v1.0.1 github.com/daytonaio/daytona/libs/api-client-go v0.183.0 github.com/daytonaio/daytona/libs/sdk-go v0.183.0 + github.com/dlclark/regexp2 v1.12.0 github.com/firecracker-microvm/firecracker-go-sdk v1.0.0 github.com/gofrs/flock v0.13.0 github.com/google/go-tdx-guest v0.3.1 @@ -47,6 +48,10 @@ require ( nhooyr.io/websocket v1.8.17 ) +require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3-0.20260218184449-befd2c18b2f0 + +replace github.com/santhosh-tekuri/jsonschema/v6 => github.com/steipete/jsonschema/v6 v6.0.3-0.20260717053323-7235d3ee9642 + require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect diff --git a/go.sum b/go.sum index a16fcfc33..728056f8b 100644 --- a/go.sum +++ b/go.sum @@ -342,6 +342,8 @@ github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8l github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY= github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= @@ -881,6 +883,8 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= +github.com/steipete/jsonschema/v6 v6.0.3-0.20260717053323-7235d3ee9642 h1:HBzDWz/MdDSutJjGP0hCU4q5hVzFXyoAafLaTsWdPBY= +github.com/steipete/jsonschema/v6 v6.0.3-0.20260717053323-7235d3ee9642/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go index 81eb87adc..3a113162b 100644 --- a/internal/cli/run_artifact_schema.go +++ b/internal/cli/run_artifact_schema.go @@ -5,80 +5,74 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "math/big" + "net/url" "os" "path" - "sort" + "reflect" + "regexp" + regexpsyntax "regexp/syntax" "strconv" "strings" + "unicode" "unicode/utf8" + + regexp2syntax "github.com/dlclark/regexp2/syntax" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" ) -type artifactSchema struct { - Type string - Required []string - Properties map[string]artifactSchema - Items *artifactSchema - Enum []interface{} - hasType bool - hasEnum bool - enumKeys map[string]struct{} -} - -type artifactSchemaWire struct { - Type json.RawMessage `json:"type"` - Required json.RawMessage `json:"required"` - Properties json.RawMessage `json:"properties"` - Items json.RawMessage `json:"items"` - Enum json.RawMessage `json:"enum"` - AnnotationSchema json.RawMessage `json:"$schema"` - AnnotationID json.RawMessage `json:"$id"` - AnnotationComment json.RawMessage `json:"$comment"` - AnnotationTitle json.RawMessage `json:"title"` - AnnotationDescription json.RawMessage `json:"description"` - AnnotationExamples json.RawMessage `json:"examples"` - AnnotationDefault json.RawMessage `json:"default"` - AnnotationDeprecated json.RawMessage `json:"deprecated"` -} +const ( + artifactSchemaResourceURL = "https://crabbox.invalid/artifact-schema.json" + maxSchemaViolations = 100 + maxSchemaDefinitionBytes = 1 * 1024 * 1024 + maxSchemaDefinitionValues = 4_096 + maxSchemaObjectNameBytes = 128 + maxSchemaResourceURLBytes = 2_048 + maxSchemaResourceURLTotal = 1 * 1024 * 1024 + maxSchemaArtifactBytes = 5 * 1024 * 1024 + maxSchemaArtifactValues = 100_000 + maxSchemaJSONDepth = 64 + maxSchemaNumberCharacters = 1_024 + maxSchemaNumberExponent = 10_000 + maxSchemaSubschemas = 2_048 + maxSchemaValidationWork = 100_000 + maxSchemaValidationBytes = 16 * 1024 * 1024 + maxSchemaNumericWork = 16 * 1024 * 1024 + maxSchemaCardinality = 2_147_483_647 + maxSchemaDiagnosticBytes = 32 * 1024 + maxSchemaLocationBytes = 1_024 + maxSchemaRegexpSourceBytes = 64 * 1024 + maxSchemaRegexpProgramWork = 100_000 + maxSchemaRegexpTotalWork = 1_000_000 +) -type schemaViolation struct { - Path string - Keyword string - Message string +type artifactSchema struct { + compiled *jsonschema.Schema + validationWeight int + numericValidationWeight int + uniqueItemsWeight int } -const maxSchemaViolations = 100 - -type schemaViolationAccumulator struct { - violations []schemaViolation - truncated bool +type boundedECMARegexp struct { + source string + compiled *regexp.Regexp } -func (a *schemaViolationAccumulator) add(violation schemaViolation) { - if a.truncated { - return - } - if len(a.violations) >= maxSchemaViolations { - a.truncated = true - return - } - a.violations = append(a.violations, violation) +func (r *boundedECMARegexp) String() string { + return r.source } -func (a *schemaViolationAccumulator) full() bool { - return a.truncated +func (r *boundedECMARegexp) MatchString(value string) bool { + return r.compiled.MatchString(value) } -func (a *schemaViolationAccumulator) result() []schemaViolation { - if a.truncated { - a.violations = append(a.violations, schemaViolation{ - Keyword: "truncated", - Message: fmt.Sprintf("additional violations omitted after the first %d", maxSchemaViolations), - }) - } - return a.violations +type schemaViolation struct { + Path string + Keyword string + Message string } func (v schemaViolation) String() string { @@ -86,7 +80,14 @@ func (v schemaViolation) String() string { if loc == "" { loc = "(root)" } - return loc + ": " + v.Message + if v.Message != "" { + return loc + ": " + v.Message + } + keyword := v.Keyword + if keyword == "" { + keyword = "/" + } + return fmt.Sprintf("%s: does not satisfy JSON Schema constraint %s", loc, keyword) } type SchemaValidationResult struct { @@ -97,141 +98,502 @@ type SchemaValidationResult struct { Error string `json:"error,omitempty"` } -var knownSchemaTypes = map[string]bool{ - "object": true, - "array": true, - "string": true, - "number": true, - "integer": true, - "boolean": true, - "null": true, -} +type rejectingSchemaLoader struct{} -var knownArtifactSchemaKeywords = map[string]bool{ - "type": true, - "required": true, - "properties": true, - "items": true, - "enum": true, - "$schema": true, - "$id": true, - "$comment": true, - "title": true, - "description": true, - "examples": true, - "default": true, - "deprecated": true, +func (rejectingSchemaLoader) Load(rawURL string) (any, error) { + return nil, fmt.Errorf("external schema reference %q is not allowed", rawURL) } -func parseArtifactSchema(data []byte) (artifactSchema, error) { - if !utf8.Valid(data) { - return artifactSchema{}, fmt.Errorf("schema is not valid UTF-8") +func parseArtifactSchema(data []byte) (*artifactSchema, error) { + schemaStats, err := scanJSONDocument(data, maxSchemaDefinitionValues, maxSchemaJSONDepth) + if err != nil { + return nil, err } - if err := rejectDuplicateJSONNames(data); err != nil { - return artifactSchema{}, err + if schemaStats.numberWork > maxSchemaNumericWork { + return nil, fmt.Errorf("schema exceeds the %d-unit numeric compilation safety budget", maxSchemaNumericWork) } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var s artifactSchema - if err := decoder.Decode(&s); err != nil { - return artifactSchema{}, fmt.Errorf("%w (supported keywords: type, required, properties, items, enum)", err) + if schemaStats.maxObjectNameBytes > maxSchemaObjectNameBytes { + return nil, fmt.Errorf("schema object name exceeds the %d-byte compilation safety limit", maxSchemaObjectNameBytes) } - if err := requireJSONDecoderEOF(decoder); err != nil { - return artifactSchema{}, fmt.Errorf("%w (schema must contain exactly one JSON value)", err) + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return nil, err + } + complexity, err := validateSchemaImplementationBounds(doc) + if err != nil { + return nil, err } - if err := validateSchemaShape(s, ""); err != nil { - return artifactSchema{}, err + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + compiler.UseLoader(rejectingSchemaLoader{}) + regexpWorkRemaining := maxSchemaRegexpTotalWork + compiler.UseRegexpEngine(func(expression string) (jsonschema.Regexp, error) { + work, err := boundedRegexpProgramWork(expression) + if err != nil { + return nil, err + } + if work > regexpWorkRemaining { + return nil, fmt.Errorf("schema regular expressions exceed the %d-unit aggregate compilation safety budget", maxSchemaRegexpTotalWork) + } + regexpWorkRemaining -= work + translated, err := translateECMARegexp(expression) + if err != nil { + return nil, err + } + compiled, err := regexp.Compile(translated) + if err != nil { + return nil, err + } + return &boundedECMARegexp{source: expression, compiled: compiled}, nil + }) + if err := compiler.AddResource(artifactSchemaResourceURL, doc); err != nil { + return nil, err + } + schema, err := compiler.Compile(artifactSchemaResourceURL) + if err != nil { + return nil, err + } + if err := validateCompiledArtifactSchemaCount(schema); err != nil { + return nil, err + } + if err := validateCompiledArtifactSchemaFormats(schema); err != nil { + return nil, err + } + if err := validateCompiledArtifactSchemaCardinalities(schema, complexity.resources); err != nil { + return nil, err + } + validationWeight, err := compiledArtifactSchemaValidationWeight(schema) + if err != nil { + return nil, err + } + numericValidationWeight, err := compiledArtifactSchemaNumericWeight(schema) + if err != nil { + return nil, err + } + uniqueItemsWeight, err := compiledArtifactSchemaAssertionWeight(schema, func(schema *jsonschema.Schema) int { + if schema.UniqueItems { + return 1 + } + return 0 + }) + if err != nil { + return nil, err } - return s, nil + return &artifactSchema{ + compiled: schema, + validationWeight: validationWeight, + numericValidationWeight: numericValidationWeight, + uniqueItemsWeight: uniqueItemsWeight, + }, nil } -func (s *artifactSchema) UnmarshalJSON(data []byte) error { - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 || trimmed[0] != '{' { - return fmt.Errorf("schema must be a JSON object") +func compiledArtifactSchemaValidationWeight(root *jsonschema.Schema) (int, error) { + active := make(map[*jsonschema.Schema]bool) + var cost func(*jsonschema.Schema) (int, error) + add := func(total *int, delta int) error { + if delta > maxSchemaValidationWork-*total { + return fmt.Errorf("schema exceeds the %d-unit expanded validation safety budget", maxSchemaValidationWork) + } + *total += delta + return nil } - var keywords map[string]json.RawMessage - if err := json.Unmarshal(data, &keywords); err != nil { - return err + addSchema := func(total *int, schema *jsonschema.Schema) error { + delta, err := cost(schema) + if err != nil { + return err + } + return add(total, delta) } - for keyword := range keywords { - if !knownArtifactSchemaKeywords[keyword] { - return fmt.Errorf("unsupported schema keyword %q", keyword) + addSchemas := func(total *int, schemas []*jsonschema.Schema) error { + for _, schema := range schemas { + if err := addSchema(total, schema); err != nil { + return err + } } + return nil } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var wire artifactSchemaWire - if err := decoder.Decode(&wire); err != nil { - return err + cost = func(schema *jsonschema.Schema) (int, error) { + if schema == nil { + return 0, nil + } + if active[schema] { + return 0, fmt.Errorf("recursive schema references are not supported by bounded artifact validation") + } + active[schema] = true + defer delete(active, schema) + if schema.DynamicRef != nil { + return 0, fmt.Errorf("schema keyword %q is not supported by bounded artifact validation", "$dynamicRef") + } + if schema.RecursiveRef != nil { + return 0, fmt.Errorf("schema keyword %q is not supported by bounded artifact validation", "$recursiveRef") + } + if schema.DraftVersion < 2019 && schema.Ref != nil { + total := 1 + if err := addSchema(&total, schema.Ref); err != nil { + return 0, err + } + return total, nil + } + + total := 1 + for _, delta := range []int{ + len(schema.Required) + schemaStringBytes(schema.Required), + len(schema.DependentRequired), len(schema.Dependencies), + } { + if err := add(&total, delta); err != nil { + return 0, err + } + } + for property, names := range schema.DependentRequired { + if err := add(&total, len(property)+len(names)+schemaStringBytes(names)); err != nil { + return 0, err + } + } + for property, dependency := range schema.Dependencies { + if err := add(&total, len(property)); err != nil { + return 0, err + } + switch dependency := dependency.(type) { + case []string: + if err := add(&total, len(dependency)+schemaStringBytes(dependency)); err != nil { + return 0, err + } + } + } + if schema.Types != nil { + if err := add(&total, len(schema.Types.ToStrings())); err != nil { + return 0, err + } + } + if schema.Enum != nil { + if err := add(&total, countJSONValues(schema.Enum.Values)+countJSONNumericWork(schema.Enum.Values)+countJSONStringBytes(schema.Enum.Values)); err != nil { + return 0, err + } + } + if schema.Const != nil { + if err := add(&total, countJSONValues(*schema.Const)+countJSONNumericWork(*schema.Const)+countJSONStringBytes(*schema.Const)); err != nil { + return 0, err + } + } + if schema.Pattern != nil { + work, err := boundedRegexpProgramWork(schema.Pattern.String()) + if err != nil { + return 0, err + } + if err := add(&total, work); err != nil { + return 0, err + } + } + for expression := range schema.PatternProperties { + work, err := boundedRegexpProgramWork(expression.String()) + if err != nil { + return 0, err + } + if err := add(&total, work); err != nil { + return 0, err + } + } + for _, number := range []*big.Rat{ + schema.Maximum, schema.Minimum, schema.ExclusiveMaximum, + schema.ExclusiveMinimum, schema.MultipleOf, + } { + if number == nil { + continue + } + bytes := (number.Num().BitLen() + number.Denom().BitLen() + 7) / 8 + if err := add(&total, max(1, bytes)); err != nil { + return 0, err + } + } + if schema.UniqueItems { + // jsonschema/v6 uses pairwise equality through 20 items, then a + // structural hash. Charge the full small-array comparison window; + // artifact values cover hashing and deep equality input size. + if err := add(&total, 20); err != nil { + return 0, err + } + } + + if err := addSchemas(&total, compiledArtifactSchemaChildren(schema)); err != nil { + return 0, err + } + return total, nil } - if err := requireJSONDecoderEOF(decoder); err != nil { - return err + return cost(root) +} + +func compiledArtifactSchemaNumericWeight(root *jsonschema.Schema) (int, error) { + return compiledArtifactSchemaAssertionWeight(root, func(schema *jsonschema.Schema) int { + total := 0 + if schema.Types != nil { + for _, name := range schema.Types.ToStrings() { + if name == "integer" || name == "number" { + total++ + } + } + } + if schema.Enum != nil { + total += len(schema.Enum.Values) + } + if schema.Const != nil { + total++ + } + if schema.UniqueItems { + total++ + } + for _, number := range []*big.Rat{ + schema.Maximum, schema.Minimum, schema.ExclusiveMaximum, + schema.ExclusiveMinimum, schema.MultipleOf, + } { + if number != nil { + total++ + } + } + return total + }) +} + +func compiledArtifactSchemaAssertionWeight(root *jsonschema.Schema, ownWeight func(*jsonschema.Schema) int) (int, error) { + active := make(map[*jsonschema.Schema]bool) + var cost func(*jsonschema.Schema) (int, error) + cost = func(schema *jsonschema.Schema) (int, error) { + if schema == nil { + return 0, nil + } + if active[schema] { + return 0, fmt.Errorf("recursive schema references are not supported by bounded artifact validation") + } + active[schema] = true + defer delete(active, schema) + + total := 0 + if schema.DraftVersion >= 2019 || schema.Ref == nil { + total = ownWeight(schema) + } + for _, child := range compiledArtifactSchemaChildren(schema) { + childCost, err := cost(child) + if err != nil { + return 0, err + } + if childCost > maxSchemaValidationWork-total { + return 0, fmt.Errorf("schema exceeds the %d-unit assertion validation safety budget", maxSchemaValidationWork) + } + total += childCost + } + return total, nil } + return cost(root) +} - if wire.Type != nil { - s.hasType = true - if err := decodeSchemaKeyword("type", wire.Type, &s.Type, false); err != nil { - return err +func compiledArtifactSchemaChildren(schema *jsonschema.Schema) []*jsonschema.Schema { + if schema.DraftVersion < 2019 && schema.Ref != nil { + return []*jsonschema.Schema{schema.Ref} + } + children := []*jsonschema.Schema{ + schema.Ref, schema.RecursiveRef, schema.Not, schema.If, schema.Then, schema.Else, + schema.PropertyNames, schema.UnevaluatedProperties, schema.Contains, + schema.Items2020, schema.UnevaluatedItems, schema.ContentSchema, + } + if schema.DynamicRef != nil { + children = append(children, schema.DynamicRef.Ref) + } + children = append(children, schema.AllOf...) + children = append(children, schema.AnyOf...) + children = append(children, schema.OneOf...) + children = append(children, schema.PrefixItems...) + for _, schemas := range []map[string]*jsonschema.Schema{ + schema.Properties, schema.DependentSchemas, + } { + for _, child := range schemas { + children = append(children, child) } } - if wire.Required != nil { - if err := decodeSchemaKeyword("required", wire.Required, &s.Required, false); err != nil { - return err + for _, child := range schema.PatternProperties { + children = append(children, child) + } + for _, value := range []any{schema.AdditionalProperties, schema.Items, schema.AdditionalItems} { + switch value := value.(type) { + case *jsonschema.Schema: + children = append(children, value) + case []*jsonschema.Schema: + children = append(children, value...) } } - if wire.Properties != nil { - var properties map[string]json.RawMessage - if err := decodeSchemaKeyword("properties", wire.Properties, &properties, false); err != nil { - return err + for _, dependency := range schema.Dependencies { + if child, ok := dependency.(*jsonschema.Schema); ok { + children = append(children, child) } - s.Properties = make(map[string]artifactSchema, len(properties)) - for key, raw := range properties { - var child artifactSchema - if err := decodeSchemaKeyword("properties."+key, raw, &child, false); err != nil { + } + return children +} + +func walkCompiledArtifactSchemaGraph(root *jsonschema.Schema, visit func(*jsonschema.Schema) error) error { + visited := make(map[*jsonschema.Schema]bool) + var walk func(*jsonschema.Schema) error + walk = func(schema *jsonschema.Schema) error { + if schema == nil || visited[schema] { + return nil + } + visited[schema] = true + for _, child := range compiledArtifactSchemaChildren(schema) { + if err := walk(child); err != nil { return err } - s.Properties[key] = child } + return visit(schema) } - if wire.Items != nil { - var items artifactSchema - if err := decodeSchemaKeyword("items", wire.Items, &items, false); err != nil { - return err + return walk(root) +} + +func validateCompiledArtifactSchemaCount(root *jsonschema.Schema) error { + count := 0 + return walkCompiledArtifactSchemaGraph(root, func(*jsonschema.Schema) error { + count++ + if count > maxSchemaSubschemas { + return fmt.Errorf("schema exceeds the %d-subschema compiled-graph limit", maxSchemaSubschemas) } - s.Items = &items - } - if wire.Enum != nil { - s.hasEnum = true - if err := decodeSchemaKeyword("enum", wire.Enum, &s.Enum, true); err != nil { + return nil + }) +} + +func validateCompiledArtifactSchemaFormats(root *jsonschema.Schema) error { + return walkCompiledArtifactSchemaGraph(root, func(schema *jsonschema.Schema) error { + if schema.DraftVersion < 2019 && schema.Format != nil && schema.Format.Name == "regex" { + return fmt.Errorf("schema format %q is not supported by bounded artifact validation", "regex") + } + return nil + }) +} + +func validateCompiledArtifactSchemaCardinalities(root *jsonschema.Schema, resources map[string]any) error { + return walkCompiledArtifactSchemaGraph(root, func(schema *jsonschema.Schema) error { + raw, err := artifactSchemaAtCompiledLocation(resources, schema.Location) + if err != nil { return err } - s.enumKeys = make(map[string]struct{}, len(s.Enum)) - for _, value := range s.Enum { - key := schemaJSONKey(value) - if _, exists := s.enumKeys[key]; exists { - return fmt.Errorf("schema keyword %q must contain unique values", "enum") + object, ok := raw.(map[string]any) + if !ok { + return nil + } + if schema.DraftVersion < 2019 && schema.Ref != nil { + return nil + } + for keyword := range cardinalitySchemaKeywords { + if (keyword == "minContains" || keyword == "maxContains") && schema.DraftVersion < 2019 { + continue + } + if err := validateSchemaCardinality(keyword, object[keyword]); err != nil { + return fmt.Errorf("%s at %s", err, schema.Location) } - s.enumKeys[key] = struct{}{} } + return nil + }) +} + +func artifactSchemaAtCompiledLocation(resources map[string]any, location string) (any, error) { + parsed, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("invalid compiled schema location %q: %w", location, err) } - return nil + pointer := parsed.Fragment + parsed.Fragment = "" + parsed.RawFragment = "" + root, ok := resources[parsed.String()] + if !ok { + return nil, fmt.Errorf("compiled schema resource %q is outside the bounded schema document", parsed.String()) + } + value, err := artifactSchemaAtJSONPointer(root, pointer) + if err != nil { + return nil, fmt.Errorf("compiled schema location %q is missing from the bounded schema document: %w", location, err) + } + return value, nil +} + +func artifactSchemaAtJSONPointer(root any, pointer string) (any, error) { + if pointer == "" { + return root, nil + } + if !strings.HasPrefix(pointer, "/") { + return nil, fmt.Errorf("location is not a JSON Pointer") + } + value := root + for _, token := range strings.Split(pointer[1:], "/") { + token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") + ok := false + switch current := value.(type) { + case map[string]any: + value, ok = current[token] + case []any: + index, err := strconv.Atoi(token) + if err == nil && index >= 0 && index < len(current) { + value = current[index] + ok = true + } else { + ok = false + } + default: + ok = false + } + if !ok { + return nil, fmt.Errorf("JSON Pointer target is missing") + } + } + return value, nil +} + +type jsonDocumentStats struct { + values int + stringBytes int + numberWork int + maxArrayLen int + maxObjectNameBytes int +} + +var ( + errJSONValueLimit = errors.New("JSON value limit") + errJSONDepthLimit = errors.New("JSON nesting-depth limit") + errJSONNumericLimit = errors.New("JSON numeric complexity limit") +) + +func validateArtifactJSONShape(data []byte) (jsonDocumentStats, error) { + return scanJSONDocument(data, maxSchemaArtifactValues, maxSchemaJSONDepth) } -func rejectDuplicateJSONNames(data []byte) error { +func scanJSONDocument(data []byte, maxValues, maxDepth int) (jsonDocumentStats, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() - if err := scanJSONValue(decoder); err != nil { - return err + stats := jsonDocumentStats{} + if err := scanJSONValue(decoder, &stats, maxValues, maxDepth, 1); err != nil { + return jsonDocumentStats{}, err + } + if err := requireJSONDecoderEOF(decoder); err != nil { + return jsonDocumentStats{}, err } - return requireJSONDecoderEOF(decoder) + return stats, nil } -func scanJSONValue(decoder *json.Decoder) error { +func scanJSONValue(decoder *json.Decoder, stats *jsonDocumentStats, maxValues, maxDepth, depth int) error { + if maxDepth > 0 && depth > maxDepth { + return fmt.Errorf("%w: JSON exceeds the %d-level nesting-depth limit", errJSONDepthLimit, maxDepth) + } + stats.values++ + if maxValues > 0 && stats.values > maxValues { + return fmt.Errorf("%w: JSON contains more than %d values", errJSONValueLimit, maxValues) + } token, err := decoder.Token() if err != nil { return err } + if number, ok := token.(json.Number); ok { + work, err := boundedJSONNumberWork(number) + if err != nil { + return fmt.Errorf("%w: %v", errJSONNumericLimit, err) + } + stats.numberWork += work + } + if text, ok := token.(string); ok { + stats.stringBytes += len(text) + } delim, isDelim := token.(json.Delim) if !isDelim { return nil @@ -246,22 +608,27 @@ func scanJSONValue(decoder *json.Decoder) error { } key, ok := keyToken.(string) if !ok { - return fmt.Errorf("schema object contains a non-string key") + return fmt.Errorf("JSON object contains a non-string name") } + stats.stringBytes += len(key) + stats.maxObjectNameBytes = max(stats.maxObjectNameBytes, len(key)) if seen[key] { - return fmt.Errorf("schema contains duplicate object name %q", key) + return fmt.Errorf("JSON contains duplicate object name %q", key) } seen[key] = true - if err := scanJSONValue(decoder); err != nil { + if err := scanJSONValue(decoder, stats, maxValues, maxDepth, depth+1); err != nil { return err } } case '[': + length := 0 for decoder.More() { - if err := scanJSONValue(decoder); err != nil { + length++ + if err := scanJSONValue(decoder, stats, maxValues, maxDepth, depth+1); err != nil { return err } } + stats.maxArrayLen = max(stats.maxArrayLen, length) } if _, err := decoder.Token(); err != nil { return err @@ -269,313 +636,1001 @@ func scanJSONValue(decoder *json.Decoder) error { return nil } -func decodeSchemaKeyword(keyword string, raw json.RawMessage, dst interface{}, useNumber bool) error { - if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return fmt.Errorf("schema keyword %q must not be null", keyword) +func boundedJSONNumberWork(number json.Number) (int, error) { + text := number.String() + if len(text) > maxSchemaNumberCharacters { + return 0, fmt.Errorf("JSON number exceeds the %d-character numeric complexity limit", maxSchemaNumberCharacters) } - decoder := json.NewDecoder(bytes.NewReader(raw)) - if useNumber { - decoder.UseNumber() + work := len(text) + exponentAt := strings.IndexAny(text, "eE") + if exponentAt < 0 { + return work, nil } - if err := decoder.Decode(dst); err != nil { - return fmt.Errorf("invalid schema keyword %q: %w", keyword, err) + exponent, err := strconv.ParseInt(text[exponentAt+1:], 10, 64) + if err != nil || exponent < -maxSchemaNumberExponent || exponent > maxSchemaNumberExponent { + return 0, fmt.Errorf("JSON number exceeds the absolute exponent limit of %d", maxSchemaNumberExponent) } - if err := requireJSONDecoderEOF(decoder); err != nil { - return fmt.Errorf("invalid schema keyword %q: %w", keyword, err) + if exponent < 0 { + exponent = -exponent } - return nil + return work + int(exponent), nil } -func requireJSONDecoderEOF(decoder *json.Decoder) error { - var trailing interface{} - if err := decoder.Decode(&trailing); err != io.EOF { - if err == nil { - return fmt.Errorf("unexpected trailing JSON value") - } - return fmt.Errorf("unexpected trailing content: %w", err) +func boundedRegexpProgramWork(expression string) (int, error) { + if len(expression) > maxSchemaRegexpSourceBytes { + return 0, fmt.Errorf("regular expression exceeds the %d-byte compilation safety limit", maxSchemaRegexpSourceBytes) } - return nil + translated, err := translateECMARegexp(expression) + if err != nil { + return 0, err + } + parsed, err := regexpsyntax.Parse(translated, regexpsyntax.Perl) + if err != nil { + return 0, err + } + work, ok := regexpProgramWork(parsed, maxSchemaRegexpProgramWork) + if !ok { + return 0, fmt.Errorf("regular expression exceeds the %d-unit compiled-program safety limit", maxSchemaRegexpProgramWork) + } + return max(1, work), nil } -func validateSchemaShape(s artifactSchema, path string) error { - if s.hasType && !knownSchemaTypes[s.Type] { - return fmt.Errorf("unknown type %q at %s", s.Type, schemaPathOrRoot(path)) +const ecmaWhitespaceClass = `\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}` + +func translateECMARegexp(expression string) (string, error) { + if _, err := regexp2syntax.Parse(expression, regexp2syntax.ECMAScript|regexp2syntax.Unicode); err != nil { + return "", err } - seenRequired := make(map[string]bool, len(s.Required)) - for _, key := range s.Required { - if seenRequired[key] { - return fmt.Errorf("duplicate required property %q at %s", key, schemaPathOrRoot(path)) + var translated strings.Builder + translated.Grow(len(expression) + 32) + inClass := false + classFirst := false + classHasContent := false + for offset := 0; offset < len(expression); { + character, width := utf8.DecodeRuneInString(expression[offset:]) + if character == utf8.RuneError && width == 1 { + return "", fmt.Errorf("regular expression contains invalid UTF-8") + } + if character == '\\' { + if offset+width >= len(expression) { + return "", fmt.Errorf("regular expression ends with an escape") + } + escaped := expression[offset+width] + switch { + case escaped >= '1' && escaped <= '9', escaped == 'k': + return "", fmt.Errorf("regular expression backreferences are not supported by bounded artifact validation") + case escaped == 'p' || escaped == 'P': + return "", fmt.Errorf("regular expression Unicode property escapes are not supported by bounded artifact validation") + case escaped == 'c': + if offset+width+1 >= len(expression) { + return "", fmt.Errorf("regular expression has an incomplete control escape") + } + letter := expression[offset+width+1] + if !((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z')) { + return "", fmt.Errorf("regular expression has an invalid control escape") + } + fmt.Fprintf(&translated, `\x{%X}`, letter&0x1f) + offset += width + 2 + classFirst = false + classHasContent = inClass + continue + case escaped == 'u': + valueOffset := offset + width + 1 + valueEnd := valueOffset + 4 + braced := false + if valueOffset < len(expression) && expression[valueOffset] == '{' { + braced = true + valueOffset++ + closing := strings.IndexByte(expression[valueOffset:], '}') + if closing < 0 { + return "", fmt.Errorf("regular expression has an incomplete Unicode escape") + } + valueEnd = valueOffset + closing + offset = valueEnd + 1 + } else { + if valueEnd > len(expression) { + return "", fmt.Errorf("regular expression has an incomplete Unicode escape") + } + offset = valueEnd + } + codepoint, err := strconv.ParseUint(expression[valueOffset:valueEnd], 16, 32) + if err != nil || codepoint > unicode.MaxRune { + return "", fmt.Errorf("regular expression Unicode escape is outside the supported scalar range") + } + if !braced && codepoint >= 0xD800 && codepoint <= 0xDBFF { + if offset+6 > len(expression) || !strings.HasPrefix(expression[offset:], `\u`) { + return "", fmt.Errorf("regular expression Unicode escape has an unpaired lead surrogate") + } + trail, trailErr := strconv.ParseUint(expression[offset+2:offset+6], 16, 16) + if trailErr != nil || trail < 0xDC00 || trail > 0xDFFF { + return "", fmt.Errorf("regular expression Unicode escape has an unpaired lead surrogate") + } + codepoint = 0x10000 + (codepoint-0xD800)<<10 + trail - 0xDC00 + offset += 6 + } else if codepoint >= 0xD800 && codepoint <= 0xDFFF { + return "", fmt.Errorf("regular expression Unicode escape has an unpaired surrogate") + } + fmt.Fprintf(&translated, `\x{%X}`, codepoint) + classFirst = false + classHasContent = inClass + continue + case escaped == 's': + if inClass { + translated.WriteString(ecmaWhitespaceClass) + } else { + translated.WriteByte('[') + translated.WriteString(ecmaWhitespaceClass) + translated.WriteByte(']') + } + case escaped == 'S': + if inClass { + return "", fmt.Errorf("regular expression \\S inside a character class is not supported by bounded artifact validation") + } + translated.WriteString(`[^` + ecmaWhitespaceClass + `]`) + case escaped == 'b' && inClass: + translated.WriteString(`\x{8}`) + case escaped == '0': + translated.WriteString(`\x{0}`) + case escaped == '/': + translated.WriteByte('/') + case escaped == '-': + translated.WriteString(`\x{2D}`) + case strings.ContainsRune(`dDwWbBfnrtv\\.^$|?*+()[]{} `, rune(escaped)): + translated.WriteByte('\\') + translated.WriteByte(escaped) + case escaped == 'x': + if offset+width+3 > len(expression) { + return "", fmt.Errorf("regular expression has an incomplete hexadecimal escape") + } + translated.WriteString(expression[offset : offset+width+3]) + offset += width + 3 + classFirst = false + classHasContent = inClass + continue + default: + return "", fmt.Errorf("regular expression escape \\%c is not supported by bounded artifact validation", escaped) + } + offset += width + 1 + classFirst = false + if inClass { + classHasContent = true + } + continue + } + if !inClass && character == '(' && strings.HasPrefix(expression[offset:], "(?:") { + translated.WriteByte('(') + offset += 3 + continue + } + if !inClass && character == '(' && strings.HasPrefix(expression[offset:], "(?") { + return "", fmt.Errorf("regular expression lookarounds and inline options are not supported by bounded artifact validation") + } + if !inClass && character == '.' { + translated.WriteString(`[^\n\r\x{2028}\x{2029}]`) + offset += width + continue + } + if !inClass && character == '$' { + translated.WriteRune(character) + offset += width + continue + } + if character == '[' && !inClass { + inClass = true + classFirst = true + classHasContent = false + translated.WriteRune(character) + offset += width + continue + } + if character == ']' && inClass { + if !classHasContent { + return "", fmt.Errorf("empty regular expression character classes are not supported by bounded artifact validation") + } else { + translated.WriteRune(character) + inClass = false + } + offset += width + continue + } + translated.WriteRune(character) + if inClass { + if classFirst && character == '^' { + classFirst = false + } else { + classFirst = false + classHasContent = true + } } - seenRequired[key] = true + offset += width } - if s.hasEnum && len(s.Enum) == 0 { - return fmt.Errorf("enum must contain at least one value at %s", schemaPathOrRoot(path)) + return translated.String(), nil +} + +func regexpProgramWork(expression *regexpsyntax.Regexp, limit int) (int, bool) { + add := func(total, delta int) (int, bool) { + if delta > limit-total { + return 0, false + } + return total + delta, true } - for _, key := range sortedSchemaKeys(s.Properties) { - if err := validateSchemaShape(s.Properties[key], schemaJoinPath(path, key)); err != nil { - return err + walkChildren := func(children []*regexpsyntax.Regexp) (int, bool) { + total := 0 + for _, child := range children { + childWork, ok := regexpProgramWork(child, limit) + if !ok { + return 0, false + } + total, ok = add(total, childWork) + if !ok { + return 0, false + } } + return total, true } - if s.Items != nil { - if err := validateSchemaShape(*s.Items, path+"[]"); err != nil { - return err + + switch expression.Op { + case regexpsyntax.OpLiteral: + return min(limit, max(1, len(expression.Rune))), len(expression.Rune) <= limit + case regexpsyntax.OpCharClass: + return min(limit, 1+len(expression.Rune)), len(expression.Rune) < limit + case regexpsyntax.OpCapture: + childWork, ok := regexpProgramWork(expression.Sub[0], limit) + if !ok { + return 0, false } + return add(childWork, 2) + case regexpsyntax.OpConcat: + return walkChildren(expression.Sub) + case regexpsyntax.OpAlternate: + total, ok := walkChildren(expression.Sub) + if !ok { + return 0, false + } + return add(total, max(0, len(expression.Sub)-1)) + case regexpsyntax.OpStar, regexpsyntax.OpPlus, regexpsyntax.OpQuest: + childWork, ok := regexpProgramWork(expression.Sub[0], limit) + if !ok { + return 0, false + } + return add(childWork, 1) + case regexpsyntax.OpRepeat: + childWork, ok := regexpProgramWork(expression.Sub[0], limit) + if !ok { + return 0, false + } + copies := expression.Max + if copies < 0 { + copies = expression.Min + 1 + } + if copies > limit || childWork+1 > limit/max(1, copies) { + return 0, false + } + return add(copies*(childWork+1), 1) + default: + return 1, true } +} + +var cardinalitySchemaKeywords = map[string]bool{ + "maxContains": true, + "maxItems": true, + "maxLength": true, + "maxProperties": true, + "minContains": true, + "minItems": true, + "minLength": true, + "minProperties": true, +} + +type artifactSchemaComplexity struct { + subschemas int + validationWeight int + resources map[string]any + resourceDrafts map[string]int + resourceURLBytes int +} + +func chargeArtifactSchemaReferenceURL(complexity *artifactSchemaComplexity, baseURL, keyword, raw string) error { + if len(raw) > maxSchemaResourceURLBytes { + return fmt.Errorf("schema %s URL exceeds the %d-byte limit", keyword, maxSchemaResourceURLBytes) + } + base, err := url.Parse(baseURL) + if err != nil { + return nil + } + reference, err := url.Parse(raw) + if err != nil { + return nil + } + resolved := base.ResolveReference(reference).String() + if len(resolved) > maxSchemaResourceURLBytes { + return fmt.Errorf("resolved schema %s URL exceeds the %d-byte limit", keyword, maxSchemaResourceURLBytes) + } + if len(resolved) > maxSchemaResourceURLTotal-complexity.resourceURLBytes { + return fmt.Errorf("schema resource and reference URLs exceed the %d-byte aggregate limit", maxSchemaResourceURLTotal) + } + complexity.resourceURLBytes += len(resolved) return nil } -func validateJSONAgainstSchema(doc []byte, schema artifactSchema) []schemaViolation { - if !utf8.Valid(doc) { - return []schemaViolation{{Keyword: "json", Message: "artifact is not valid UTF-8"}} +func validateSchemaImplementationBounds(root any) (artifactSchemaComplexity, error) { + complexity := artifactSchemaComplexity{ + resources: map[string]any{artifactSchemaResourceURL: root}, + resourceDrafts: map[string]int{artifactSchemaResourceURL: 2020}, + resourceURLBytes: len(artifactSchemaResourceURL), } - var value interface{} - decoder := json.NewDecoder(bytes.NewReader(doc)) - decoder.UseNumber() - if err := decoder.Decode(&value); err != nil { - return []schemaViolation{{Keyword: "json", Message: fmt.Sprintf("artifact is not valid JSON: %v", err)}} + visitedObjects := make(map[uintptr]bool) + addValidationWeight := func(delta int) error { + if delta > maxSchemaValidationWork-complexity.validationWeight { + return fmt.Errorf("schema exceeds the %d-unit validation safety budget", maxSchemaValidationWork) + } + complexity.validationWeight += delta + return nil } - if err := requireJSONDecoderEOF(decoder); err != nil { - return []schemaViolation{{Keyword: "json", Message: fmt.Sprintf("artifact is not valid JSON: %v", err)}} - } - var out schemaViolationAccumulator - validateSchemaValue(value, schema, "", &out) - return out.result() -} - -func validateSchemaValue(value interface{}, schema artifactSchema, path string, out *schemaViolationAccumulator) { - if schema.hasType && !schemaTypeMatches(schema.Type, value) { - out.add(schemaViolation{ - Path: path, - Keyword: "type", - Message: fmt.Sprintf("expected type %s, got %s", schema.Type, schemaTypeName(value)), - }) - return - } - if schema.hasEnum && !schemaEnumContains(schema.enumKeys, value) { - out.add(schemaViolation{ - Path: path, - Keyword: "enum", - Message: "value is not one of the allowed values", - }) - return - } - switch v := value.(type) { - case map[string]interface{}: - for _, req := range schema.Required { - if out.full() { - return - } - if _, ok := v[req]; !ok { - out.add(schemaViolation{ - Path: schemaJoinPath(path, req), - Keyword: "required", - Message: fmt.Sprintf("missing required property %q", req), - }) - } - } - for _, key := range sortedSchemaKeys(schema.Properties) { - if out.full() { - return - } - if child, ok := v[key]; ok { - validateSchemaValue(child, schema.Properties[key], schemaJoinPath(path, key), out) - } - } - case []interface{}: - if schema.Items != nil { - for i, item := range v { - if out.full() { - return - } - validateSchemaValue(item, *schema.Items, fmt.Sprintf("%s[%d]", path, i), out) - } - } - } -} - -func schemaTypeMatches(t string, value interface{}) bool { - switch t { - case "object": - _, ok := value.(map[string]interface{}) - return ok - case "array": - _, ok := value.([]interface{}) - return ok - case "string": - _, ok := value.(string) - return ok - case "boolean": - _, ok := value.(bool) - return ok - case "null": - return value == nil - case "number": - _, ok := value.(json.Number) - return ok - case "integer": - n, ok := value.(json.Number) + var walk func(any, int, bool, string) error + walk = func(value any, draft int, isRoot bool, baseURL string) error { + schema, ok := value.(map[string]any) if !ok { - return false + if _, ok := value.(bool); !ok { + return fmt.Errorf("schema subschema must be an object or boolean") + } + complexity.subschemas++ + if complexity.subschemas > maxSchemaSubschemas { + return fmt.Errorf("schema exceeds the %d-subschema limit", maxSchemaSubschemas) + } + return addValidationWeight(1) } - canonical, ok := canonicalizeJSONNumber(n) - return ok && canonical.isInteger() - default: - return true + identity := reflect.ValueOf(schema).Pointer() + if visitedObjects[identity] { + return nil + } + visitedObjects[identity] = true + draft = artifactSchemaObjectDraft(schema, draft, isRoot) + resourceURL, isResource, err := artifactSchemaResourceBase(schema, draft, baseURL) + if err != nil { + return err + } + if isRoot { + complexity.resourceDrafts[baseURL] = draft + } + if isResource { + baseURL = resourceURL + if _, exists := complexity.resources[resourceURL]; !exists { + if len(resourceURL) > maxSchemaResourceURLBytes { + return fmt.Errorf("schema resource URL exceeds the %d-byte limit", maxSchemaResourceURLBytes) + } + if len(resourceURL) > maxSchemaResourceURLTotal-complexity.resourceURLBytes { + return fmt.Errorf("schema resource URLs exceed the %d-byte aggregate limit", maxSchemaResourceURLTotal) + } + complexity.resourceURLBytes += len(resourceURL) + complexity.resources[resourceURL] = schema + complexity.resourceDrafts[resourceURL] = draft + } + } + referenceKeywords := []string{"$ref"} + if isRoot || isResource { + referenceKeywords = append(referenceKeywords, "$schema") + } + if draft == 2019 { + referenceKeywords = append(referenceKeywords, "$recursiveRef") + } + if draft >= 2020 { + referenceKeywords = append(referenceKeywords, "$dynamicRef") + } + for _, keyword := range referenceKeywords { + if raw, ok := schema[keyword].(string); ok { + if err := chargeArtifactSchemaReferenceURL(&complexity, baseURL, keyword, raw); err != nil { + return err + } + } + } + + complexity.subschemas++ + if complexity.subschemas > maxSchemaSubschemas { + return fmt.Errorf("schema exceeds the %d-subschema limit", maxSchemaSubschemas) + } + if err := addValidationWeight(1); err != nil { + return err + } + rawReference, hasReference := schema["$ref"].(string) + if draft < 2019 && hasReference { + if target, targetBase, targetDraft, ok := artifactSchemaReferenceTarget( + complexity.resources, complexity.resourceDrafts, baseURL, rawReference, + ); ok { + if err := walk(target, targetDraft, false, targetBase); err != nil { + return err + } + } + return nil + } + if draft < 2019 && schema["format"] == "regex" { + return fmt.Errorf("schema format %q is not supported by bounded artifact validation", "regex") + } + for keyword := range cardinalitySchemaKeywords { + if (keyword == "minContains" || keyword == "maxContains") && draft < 2019 { + continue + } + if err := validateSchemaCardinality(keyword, schema[keyword]); err != nil { + return err + } + } + for _, keyword := range []string{"required", "type"} { + if values, ok := schema[keyword].([]any); ok { + if err := addValidationWeight(len(values)); err != nil { + return err + } + } + } + for _, keyword := range []string{"enum"} { + if value, ok := schema[keyword]; ok { + if err := addValidationWeight(countJSONValues(value)); err != nil { + return err + } + } + } + if draft >= 6 { + if value, ok := schema["const"]; ok { + if err := addValidationWeight(countJSONValues(value)); err != nil { + return err + } + } + } + dependencyKeywords := []string{} + if draft >= 2019 { + dependencyKeywords = append(dependencyKeywords, "dependentRequired") + } else { + dependencyKeywords = append(dependencyKeywords, "dependencies") + } + for _, keyword := range dependencyKeywords { + if dependencies, ok := schema[keyword].(map[string]any); ok { + for _, dependency := range dependencies { + if err := addValidationWeight(1); err != nil { + return err + } + if names, ok := dependency.([]any); ok { + if err := addValidationWeight(len(names)); err != nil { + return err + } + } + } + } + } + for keyword, child := range schema { + switch keyword { + case "additionalProperties", "not": + if err := walk(child, draft, false, baseURL); err != nil { + return err + } + case "additionalItems": + if draft < 2020 { + if err := walk(child, draft, false, baseURL); err != nil { + return err + } + } + case "contains", "propertyNames": + if draft >= 6 { + if err := walk(child, draft, false, baseURL); err != nil { + return err + } + } + case "else", "if", "then": + if draft >= 7 { + if err := walk(child, draft, false, baseURL); err != nil { + return err + } + } + case "contentSchema", "unevaluatedItems", "unevaluatedProperties": + if draft >= 2019 { + if err := walk(child, draft, false, baseURL); err != nil { + return err + } + } + case "items": + if children, ok := child.([]any); ok && draft < 2020 { + for _, item := range children { + if err := walk(item, draft, false, baseURL); err != nil { + return err + } + } + } else if err := walk(child, draft, false, baseURL); err != nil { + return err + } + case "allOf", "anyOf", "oneOf": + children, ok := child.([]any) + if !ok { + return fmt.Errorf("schema keyword %q must contain an array of schemas", keyword) + } + for _, item := range children { + if err := walk(item, draft, false, baseURL); err != nil { + return err + } + } + case "prefixItems": + if draft >= 2020 { + children, ok := child.([]any) + if !ok { + return fmt.Errorf("schema keyword %q must contain an array of schemas", keyword) + } + for _, item := range children { + if err := walk(item, draft, false, baseURL); err != nil { + return err + } + } + } + case "patternProperties", "properties": + children, ok := child.(map[string]any) + if !ok { + return fmt.Errorf("schema keyword %q must contain an object", keyword) + } + for _, item := range children { + if err := walk(item, draft, false, baseURL); err != nil { + return err + } + } + case "definitions": + if draft < 2019 { + if err := walkSchemaMapKeyword(keyword, child, draft, baseURL, walk); err != nil { + return err + } + } + case "$defs", "dependentSchemas": + if draft >= 2019 { + if err := walkSchemaMapKeyword(keyword, child, draft, baseURL, walk); err != nil { + return err + } + } + case "dependencies": + if draft < 2019 { + children, ok := child.(map[string]any) + if !ok { + return fmt.Errorf("schema keyword %q must contain an object", keyword) + } + for _, item := range children { + if _, ok := item.([]any); ok { + continue + } + if err := walk(item, draft, false, baseURL); err != nil { + return err + } + } + } + } + } + if hasReference { + if target, targetBase, targetDraft, ok := artifactSchemaReferenceTarget( + complexity.resources, complexity.resourceDrafts, baseURL, rawReference, + ); ok { + if err := walk(target, targetDraft, false, targetBase); err != nil { + return err + } + } + } + return nil + } + if err := walk(root, 2020, true, artifactSchemaResourceURL); err != nil { + return artifactSchemaComplexity{}, err } + return complexity, nil } -func schemaTypeName(value interface{}) string { - switch value.(type) { - case map[string]interface{}: - return "object" - case []interface{}: - return "array" - case string: - return "string" - case bool: - return "boolean" - case json.Number: - return "number" - case nil: - return "null" - default: - return fmt.Sprintf("%T", value) +func artifactSchemaReferenceTarget( + resources map[string]any, + resourceDrafts map[string]int, + baseURL, rawReference string, +) (target any, targetBase string, targetDraft int, ok bool) { + base, err := url.Parse(baseURL) + if err != nil { + return nil, "", 0, false + } + reference, err := url.Parse(rawReference) + if err != nil { + return nil, "", 0, false + } + resolved := base.ResolveReference(reference) + pointer := resolved.Fragment + resolved.Fragment = "" + resolved.RawFragment = "" + root, ok := resources[resolved.String()] + if !ok || (pointer != "" && !strings.HasPrefix(pointer, "/")) { + return nil, "", 0, false + } + target, err = artifactSchemaAtJSONPointer(root, pointer) + if err != nil { + return nil, "", 0, false } + return target, resolved.String(), resourceDrafts[resolved.String()], true } -func schemaEnumContains(enumKeys map[string]struct{}, value interface{}) bool { - _, ok := enumKeys[schemaJSONKey(value)] - return ok +func walkSchemaMapKeyword(keyword string, value any, draft int, baseURL string, walk func(any, int, bool, string) error) error { + children, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("schema keyword %q must contain an object", keyword) + } + for _, child := range children { + if err := walk(child, draft, false, baseURL); err != nil { + return err + } + } + return nil } -func schemaJSONKey(value interface{}) string { - var out strings.Builder - appendSchemaJSONKey(&out, value) - return out.String() +func artifactSchemaObjectDraft(schema map[string]any, inherited int, isRoot bool) int { + rawSchema, ok := schema["$schema"].(string) + if !ok { + return inherited + } + declared, ok := artifactSchemaDraft(rawSchema) + if !ok { + return inherited + } + if isRoot { + return declared + } + idKeyword := "$id" + if declared == 4 { + idKeyword = "id" + } + id, _ := schema[idKeyword].(string) + if id == "" { + return inherited + } + return declared } -func appendSchemaJSONKey(out *strings.Builder, value interface{}) { - switch typed := value.(type) { - case nil: - out.WriteByte('z') - case bool: - if typed { - out.WriteString("b1") - } else { - out.WriteString("b0") +func artifactSchemaResourceBase(schema map[string]any, draft int, current string) (string, bool, error) { + keyword := "$id" + if draft == 4 { + keyword = "id" + } + raw, ok := schema[keyword].(string) + if !ok || raw == "" { + return "", false, nil + } + if len(raw) > maxSchemaResourceURLBytes { + return "", false, fmt.Errorf("schema resource ID exceeds the %d-byte limit", maxSchemaResourceURLBytes) + } + base, err := url.Parse(current) + if err != nil { + return "", false, nil + } + reference, err := url.Parse(raw) + if err != nil { + return "", false, nil + } + resolved := base.ResolveReference(reference) + resolved.Fragment = "" + resolved.RawFragment = "" + base.Fragment = "" + base.RawFragment = "" + if resolved.String() == base.String() { + return "", false, nil + } + if len(resolved.String()) > maxSchemaResourceURLBytes { + return "", false, fmt.Errorf("resolved schema resource URL exceeds the %d-byte limit", maxSchemaResourceURLBytes) + } + return resolved.String(), true, nil +} + +func artifactSchemaDraft(raw string) (int, bool) { + normalized := strings.TrimSuffix(raw, "#") + normalized = strings.TrimPrefix(normalized, "http://") + normalized = strings.TrimPrefix(normalized, "https://") + switch normalized { + case "json-schema.org/schema", "json-schema.org/draft/2020-12/schema": + return 2020, true + case "json-schema.org/draft/2019-09/schema": + return 2019, true + case "json-schema.org/draft-07/schema": + return 7, true + case "json-schema.org/draft-06/schema": + return 6, true + case "json-schema.org/draft-04/schema": + return 4, true + default: + return 0, false + } +} + +func countJSONValues(value any) int { + count := 1 + switch value := value.(type) { + case []any: + for _, child := range value { + count += countJSONValues(child) + if count > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } } - case string: - out.WriteByte('s') - out.WriteString(strconv.Quote(typed)) - case json.Number: - canonical, ok := canonicalizeJSONNumber(typed) - if !ok { - out.WriteString("invalid-number:") - out.WriteString(typed.String()) - return + case map[string]any: + for _, child := range value { + count += countJSONValues(child) + if count > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } } - out.WriteByte('n') - if canonical.negative { - out.WriteByte('-') + } + return count +} + +func countJSONNumericWork(value any) int { + work := 0 + switch value := value.(type) { + case json.Number: + work, _ = boundedJSONNumberWork(value) + case []any: + for _, child := range value { + work += countJSONNumericWork(child) + if work > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } } - out.WriteString(canonical.digits) - out.WriteByte('e') - out.WriteString(canonical.exponent.String()) - out.WriteByte(';') - case []interface{}: - out.WriteByte('[') - for _, item := range typed { - appendSchemaJSONKey(out, item) - out.WriteByte(',') + case map[string]any: + for _, child := range value { + work += countJSONNumericWork(child) + if work > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } } - out.WriteByte(']') - case map[string]interface{}: - out.WriteByte('{') - keys := make([]string, 0, len(typed)) - for key := range typed { - keys = append(keys, key) + } + return work +} + +func countJSONStringBytes(value any) int { + bytes := 0 + switch value := value.(type) { + case string: + bytes = len(value) + case []any: + for _, child := range value { + bytes += countJSONStringBytes(child) + if bytes > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } } - sort.Strings(keys) - for _, key := range keys { - out.WriteString(strconv.Quote(key)) - out.WriteByte(':') - appendSchemaJSONKey(out, typed[key]) - out.WriteByte(',') + case map[string]any: + for key, child := range value { + bytes += len(key) + countJSONStringBytes(child) + if bytes > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } } - out.WriteByte('}') } + return bytes } -type canonicalJSONNumber struct { - negative bool - digits string - exponent big.Int +func schemaStringBytes(values []string) int { + bytes := 0 + for _, value := range values { + bytes += len(value) + if bytes > maxSchemaValidationWork { + return maxSchemaValidationWork + 1 + } + } + return bytes } -func canonicalizeJSONNumber(number json.Number) (canonicalJSONNumber, bool) { - text := number.String() - negative := strings.HasPrefix(text, "-") - if negative { - text = text[1:] +func validateSchemaCardinality(keyword string, value any) error { + number, ok := value.(json.Number) + if !ok { + return nil + } + cardinality, ok := new(big.Rat).SetString(number.String()) + if !ok || !cardinality.IsInt() || cardinality.Sign() < 0 { + return nil } + if cardinality.Num().Cmp(big.NewInt(maxSchemaCardinality)) > 0 { + return fmt.Errorf("schema keyword %q exceeds the supported cardinality limit of %d", keyword, maxSchemaCardinality) + } + return nil +} - exponent := new(big.Int) - if index := strings.IndexAny(text, "eE"); index >= 0 { - parsed, ok := new(big.Int).SetString(text[index+1:], 10) - if !ok { - return canonicalJSONNumber{}, false +func requireJSONDecoderEOF(decoder *json.Decoder) error { + var trailing interface{} + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing JSON value") } - exponent.Set(parsed) - text = text[:index] + return fmt.Errorf("unexpected trailing content: %w", err) } + return nil +} - fractionDigits := 0 - if index := strings.IndexByte(text, '.'); index >= 0 { - fractionDigits = len(text) - index - 1 - text = text[:index] + text[index+1:] +func validateJSONAgainstSchema(doc []byte, schema *artifactSchema) []schemaViolation { + stats, err := validateArtifactJSONShape(doc) + if err != nil { + message := "artifact is not valid unambiguous JSON" + if errors.Is(err, errJSONValueLimit) { + message = fmt.Sprintf("artifact JSON exceeds the %d-value validation limit", maxSchemaArtifactValues) + } else if errors.Is(err, errJSONNumericLimit) { + message = "artifact JSON exceeds the numeric complexity limit" + } else if errors.Is(err, errJSONDepthLimit) { + message = fmt.Sprintf("artifact JSON exceeds the %d-level nesting-depth limit", maxSchemaJSONDepth) + } + return []schemaViolation{{Keyword: "json", Message: message}} + } + if stats.values > 0 && schema.validationWeight > maxSchemaValidationWork/stats.values { + return []schemaViolation{{ + Keyword: "complexity", + Message: fmt.Sprintf("artifact and schema exceed the %d-unit validation safety budget", maxSchemaValidationWork), + }} } - text = strings.TrimLeft(text, "0") - if text == "" { - return canonicalJSONNumber{digits: "0"}, true + if stats.stringBytes > 0 && schema.validationWeight > maxSchemaValidationBytes/stats.stringBytes { + return []schemaViolation{{ + Keyword: "complexity", + Message: fmt.Sprintf("artifact strings and schema exceed the %d-byte validation safety budget", maxSchemaValidationBytes), + }} } + if stats.numberWork > 0 && schema.numericValidationWeight > maxSchemaNumericWork/stats.numberWork { + return []schemaViolation{{ + Keyword: "complexity", + Message: fmt.Sprintf("artifact numbers and schema exceed the %d-unit numeric validation safety budget", maxSchemaNumericWork), + }} + } + if schema.uniqueItemsWeight > 0 && stats.maxArrayLen > 1 { + if exceedsBoundedProduct(maxSchemaValidationWork, schema.uniqueItemsWeight, stats.maxArrayLen, stats.values) { + return []schemaViolation{{ + Keyword: "complexity", + Message: "artifact and schema exceed the uniqueItems structural comparison safety budget", + }} + } + if exceedsBoundedProduct(maxSchemaValidationBytes, schema.uniqueItemsWeight, stats.maxArrayLen, stats.stringBytes) { + return []schemaViolation{{ + Keyword: "complexity", + Message: "artifact strings and schema exceed the uniqueItems byte comparison safety budget", + }} + } + if exceedsBoundedProduct(maxSchemaNumericWork, schema.uniqueItemsWeight, stats.maxArrayLen, stats.numberWork) { + return []schemaViolation{{ + Keyword: "complexity", + Message: "artifact numbers and schema exceed the uniqueItems numeric comparison safety budget", + }} + } + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(doc)) + if err != nil { + return []schemaViolation{{Keyword: "json", Message: "artifact is not valid JSON"}} + } + validationErrValue := schema.compiled.Validate(value) + if validationErrValue != nil { + var validationErr *jsonschema.ValidationError + if !errors.As(validationErrValue, &validationErr) { + return []schemaViolation{{Message: "artifact did not satisfy its JSON Schema"}} + } + return boundedSchemaViolations(validationErr) + } + return nil +} - exponent.Sub(exponent, new(big.Int).SetInt64(int64(fractionDigits))) - trimmed := strings.TrimRight(text, "0") - exponent.Add(exponent, new(big.Int).SetInt64(int64(len(text)-len(trimmed)))) - return canonicalJSONNumber{negative: negative, digits: trimmed, exponent: *exponent}, true +func exceedsBoundedProduct(limit int, factors ...int) bool { + remaining := limit + for _, factor := range factors { + if factor <= 0 { + return false + } + if factor > remaining { + return true + } + remaining /= factor + } + return false } -func (n canonicalJSONNumber) isInteger() bool { - return n.digits == "0" || n.exponent.Sign() >= 0 +func boundedSchemaViolations(validationErr *jsonschema.ValidationError) []schemaViolation { + violations := make([]schemaViolation, 0, maxSchemaViolations) + seen := make(map[string]bool) + stack := []*jsonschema.ValidationError{validationErr} + totalBytes := 0 + truncation := schemaViolation{ + Keyword: "truncated", + Message: "additional violations omitted after the bounded diagnostic budget", + } + truncationBytes := len(truncation.String()) + for len(stack) > 0 { + last := len(stack) - 1 + current := stack[last] + stack = stack[:last] + if len(current.Causes) > 0 { + for i := len(current.Causes) - 1; i >= 0; i-- { + stack = append(stack, current.Causes[i]) + } + continue + } + path := boundedJSONPointer(current.InstanceLocation) + keyword := boundedSchemaKeywordLocation(current) + key := path + "\x00" + keyword + if seen[key] { + continue + } + seen[key] = true + violation := schemaViolation{Path: path, Keyword: keyword} + renderedBytes := len(violation.String()) + if len(violations) == maxSchemaViolations || renderedBytes > maxSchemaDiagnosticBytes-totalBytes-truncationBytes { + return append(violations, truncation) + } + totalBytes += renderedBytes + violations = append(violations, violation) + } + if len(violations) == 0 { + violations = append(violations, schemaViolation{Message: "artifact did not satisfy its JSON Schema"}) + } + return violations } -func schemaJoinPath(base, key string) string { - if base == "" { - return key +func boundedJSONPointer(tokens []string) string { + return boundedSchemaLocation("", tokens) +} + +func boundedSchemaKeywordLocation(validationErr *jsonschema.ValidationError) string { + fragment := "" + if hash := strings.IndexByte(validationErr.SchemaURL, '#'); hash >= 0 { + fragment = validationErr.SchemaURL[hash+1:] } - return base + "." + key + return boundedSchemaLocation(fragment, validationErr.ErrorKind.KeywordPath()) } -func schemaPathOrRoot(path string) string { - if path == "" { - return "(root)" +func boundedSchemaLocation(prefix string, tokens []string) string { + const marker = "..." + contentLimit := maxSchemaLocationBytes - len(marker) + var result strings.Builder + result.Grow(min(maxSchemaLocationBytes, len(prefix)+32)) + truncated := false + if len(prefix) > contentLimit { + result.WriteString(prefix[:contentLimit]) + truncated = true + } else { + result.WriteString(prefix) + } + for _, token := range tokens { + if truncated { + break + } + if result.Len()+1 > contentLimit { + truncated = true + break + } + result.WriteByte('/') + for _, character := range token { + encoded := safeSchemaLocationRune(character) + switch character { + case '~': + encoded = "~0" + case '/': + encoded = "~1" + } + if result.Len()+len(encoded) > contentLimit { + truncated = true + break + } + result.WriteString(encoded) + } + } + if truncated { + result.WriteString(marker) } - return path + return result.String() } -func sortedSchemaKeys(m map[string]artifactSchema) []string { - keys := make([]string, 0, len(m)) - for key := range m { - keys = append(keys, key) +func safeSchemaLocationRune(character rune) string { + if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' || isBidirectionalControl(character) { + if character <= 0xffff { + return fmt.Sprintf("\\u%04X", character) + } + return fmt.Sprintf("\\U%08X", character) } - sort.Strings(keys) - return keys + return string(character) +} + +func isBidirectionalControl(character rune) bool { + return character == '\u061c' || character == '\u200e' || character == '\u200f' || + (character >= '\u202a' && character <= '\u202e') || + (character >= '\u2066' && character <= '\u2069') } type loadedArtifactSchema struct { remote string schemaPath string - schema artifactSchema + schema *artifactSchema } func parseRequireArtifactSchemaSpec(value string) (remote, schemaPath string, err error) { @@ -613,7 +1668,7 @@ func loadRequireArtifactSchemas(values []string) ([]loadedArtifactSchema, error) return nil, exit(2, "--require-artifact-schema lists %q more than once", remote) } seen[remote] = true - data, err := os.ReadFile(schemaPath) + data, err := readBoundedSchemaDefinition(schemaPath) if err != nil { return nil, exit(2, "--require-artifact-schema: read schema %s: %v", schemaPath, err) } @@ -626,7 +1681,21 @@ func loadRequireArtifactSchemas(values []string) ([]loadedArtifactSchema, error) return out, nil } -const maxSchemaArtifactBytes = 5 * 1024 * 1024 +func readBoundedSchemaDefinition(schemaPath string) ([]byte, error) { + file, err := os.Open(schemaPath) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxSchemaDefinitionBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxSchemaDefinitionBytes { + return nil, fmt.Errorf("schema exceeds the %d-byte limit", maxSchemaDefinitionBytes) + } + return data, nil +} type remoteArtifactReader func(ctx context.Context, target SSHTarget, workdir, remote string, maxBytes int) ([]byte, error) diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go index 26f47bb6d..ab18adcff 100644 --- a/internal/cli/run_artifact_schema_test.go +++ b/internal/cli/run_artifact_schema_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "strconv" "strings" "testing" ) @@ -28,11 +29,8 @@ func TestParseArtifactSchema(t *testing.T) { if err != nil { t.Fatalf("parseArtifactSchema() unexpected error: %v", err) } - if schema.Type != "object" || len(schema.Required) != 2 { - t.Fatalf("parsed schema shape wrong: %+v", schema) - } - if schema.Properties["items"].Items == nil { - t.Fatalf("nested array items schema not parsed") + if violations := validateJSONAgainstSchema([]byte(`{"status":"passed","items":[{"name":"x"}]}`), schema); len(violations) != 0 { + t.Fatalf("compiled schema rejected valid document: %v", violations) } }) @@ -55,7 +53,7 @@ func TestParseArtifactSchema(t *testing.T) { t.Run("unknown type keyword is rejected", func(t *testing.T) { _, err := parseArtifactSchema([]byte(`{"type": "timestamp"}`)) - if err == nil || !strings.Contains(err.Error(), "unknown type") { + if err == nil || !strings.Contains(err.Error(), "/type") { t.Fatalf("expected unknown-type error, got %v", err) } }) @@ -82,10 +80,7 @@ func TestParseArtifactSchemaRejectsInvalidKeywordShapes(t *testing.T) { {"null properties", `{"properties":null}`}, {"null property schema", `{"properties":{"x":null}}`}, {"null items", `{"items":null}`}, - {"empty enum", `{"enum":[]}`}, {"null enum", `{"enum":null}`}, - {"duplicate numeric enum", `{"enum":[1,1.0]}`}, - {"duplicate object enum", `{"enum":[{"x":1,"y":2},{"y":2.0,"x":1.0}]}`}, {"duplicate root keyword", `{"type":"object","type":"string"}`}, {"duplicate nested keyword", `{"properties":{"x":{"required":["a"],"required":[]}}}`}, {"duplicate property schema", `{"properties":{"x":{"type":"string"},"x":{"type":"number"}}}`}, @@ -129,27 +124,27 @@ func TestValidateJSONAgainstSchema(t *testing.T) { { name: "missing required field", doc: `{"status":"passed","items":[]}`, - wantPath: "count", + wantPath: "", }, { name: "wrong scalar type", doc: `{"status":"passed","count":"three","items":[]}`, - wantPath: "count", + wantPath: "/count", }, { name: "enum mismatch", doc: `{"status":"skipped","count":1,"items":[]}`, - wantPath: "status", + wantPath: "/status", }, { name: "nested object property wrong type", doc: `{"status":"passed","count":1,"items":[],"config":{"retries":"nope"}}`, - wantPath: "config.retries", + wantPath: "/config/retries", }, { name: "array element violation reports index path", doc: `{"status":"passed","count":1,"items":[{"name":"ok"},{"nope":true}]}`, - wantPath: "items[1].name", + wantPath: "/items/1", }, { name: "non-JSON document is a single violation, not a crash", @@ -195,7 +190,7 @@ func TestValidateJSONAgainstSchemaTypeMismatchDoesNotCascade(t *testing.T) { t.Fatalf("schema parse failed: %v", err) } violations := validateJSONAgainstSchema([]byte(`"a bare string"`), schema) - if len(violations) != 1 || violations[0].Keyword != "type" { + if len(violations) != 1 || violations[0].Keyword != "/type" { t.Fatalf("expected exactly one type violation, got %v", violations) } } @@ -207,7 +202,7 @@ func TestValidateJSONAgainstSchemaPreservesExactNumbers(t *testing.T) { t.Fatalf("schema parse failed: %v", err) } violations := validateJSONAgainstSchema([]byte(`9007199254740993`), schema) - if len(violations) != 1 || violations[0].Keyword != "enum" { + if len(violations) != 1 || violations[0].Keyword != "/enum" { t.Fatalf("expected exact enum mismatch, got %v", violations) } }) @@ -228,36 +223,62 @@ func TestValidateJSONAgainstSchemaPreservesExactNumbers(t *testing.T) { t.Fatalf("schema parse failed: %v", err) } violations := validateJSONAgainstSchema([]byte(`1.0000000000000001`), schema) - if len(violations) != 1 || violations[0].Keyword != "type" { + if len(violations) != 1 || violations[0].Keyword != "/type" { t.Fatalf("expected exact integer mismatch, got %v", violations) } }) t.Run("large exponent remains exact without expansion", func(t *testing.T) { - schema, err := parseArtifactSchema([]byte(`{"type":"integer","enum":[1e1000001]}`)) + schema, err := parseArtifactSchema([]byte(`{"type":"integer","enum":[1e10000]}`)) if err != nil { t.Fatalf("schema parse failed: %v", err) } - if violations := validateJSONAgainstSchema([]byte(`10e1000000`), schema); len(violations) != 0 { + if violations := validateJSONAgainstSchema([]byte(`10e9999`), schema); len(violations) != 0 { t.Fatalf("expected equivalent large-exponent integer to pass, got %v", violations) } - if violations := validateJSONAgainstSchema([]byte(`1.1e-1000001`), schema); len(violations) == 0 { + if violations := validateJSONAgainstSchema([]byte(`1.1e-10000`), schema); len(violations) == 0 { t.Fatalf("expected distinct large-exponent fraction to fail") } }) } func TestValidateJSONAgainstSchemaEnumDiagnosticDoesNotIncludeValue(t *testing.T) { - schema, err := parseArtifactSchema([]byte(`{"enum":["allowed"]}`)) + schema, err := parseArtifactSchema([]byte(`{"type":"string","enum":["allowed"],"pattern":"^allowed$"}`)) if err != nil { t.Fatalf("schema parse failed: %v", err) } violations := validateJSONAgainstSchema([]byte(`"sensitive-value"`), schema) - if len(violations) != 1 { + if len(violations) != 1 || violations[0].Keyword != "/enum" { t.Fatalf("expected one enum violation, got %v", violations) } if strings.Contains(violations[0].String(), "sensitive-value") { - t.Fatalf("enum diagnostic leaked rejected value: %s", violations[0]) + t.Fatalf("schema diagnostic leaked rejected value: %s", violations[0]) + } + for _, violation := range violations { + if strings.Contains(violation.String(), "sensitive-value") { + t.Fatalf("schema diagnostic leaked rejected value: %s", violation) + } + } +} + +func TestValidateJSONAgainstSchemaRejectsDuplicateObjectNamesWithoutLeakingThem(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"object"}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`{"private-name":1,"private-name":2}`), schema) + if len(violations) != 1 || !strings.Contains(violations[0].Message, "unambiguous JSON") { + t.Fatalf("expected one duplicate-name violation, got %v", violations) + } + if strings.Contains(violations[0].String(), "private-name") { + t.Fatalf("duplicate-name diagnostic leaked artifact name: %s", violations[0]) + } + for _, name := range []string{"more than", "number exceeds", "nesting-depth"} { + doc := `{` + strconv.Quote(name) + `:1,` + strconv.Quote(name) + `:2}` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || !strings.Contains(violations[0].Message, "unambiguous JSON") { + t.Fatalf("duplicate name %q selected a limit diagnostic: %v", name, violations) + } } } @@ -280,6 +301,785 @@ func TestValidateJSONAgainstSchemaBoundsViolations(t *testing.T) { } } +func TestSchemaViolationStringDistinguishesRootFromEmptyProperty(t *testing.T) { + if got := (schemaViolation{Keyword: "/required"}).String(); !strings.HasPrefix(got, "(root):") { + t.Fatalf("root diagnostic=%q", got) + } + if got := (schemaViolation{Path: "/", Keyword: "/properties//type"}).String(); !strings.HasPrefix(got, "/:") { + t.Fatalf("empty-name property diagnostic=%q", got) + } +} + +func TestValidateJSONAgainstSchemaEscapesUnsafeLocationCharacters(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"additionalProperties":{"type":"string"}}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`{"line\n\u001b\u2028\u2029\u202e":1}`), schema) + if len(violations) != 1 { + t.Fatalf("violations=%v", violations) + } + rendered := violations[0].String() + for _, unsafe := range []string{"\n", "\x1b", "\u2028", "\u2029", "\u202e"} { + if strings.Contains(rendered, unsafe) { + t.Fatalf("unsafe location character leaked in %q", rendered) + } + } + if !strings.Contains(rendered, `\u000A`) || !strings.Contains(rendered, `\u001B`) || + !strings.Contains(rendered, `\u2028`) || !strings.Contains(rendered, `\u2029`) || + !strings.Contains(rendered, `\u202E`) { + t.Fatalf("unsafe location characters not visibly escaped: %q", rendered) + } +} + +func TestValidateJSONAgainstSchemaBoundsDiagnosticBytes(t *testing.T) { + schemaJSON := `{"additionalProperties":{"allOf":[` + strings.Repeat(`false,`, 149) + `false]}}` + schema, err := parseArtifactSchema([]byte(schemaJSON)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + longName := strings.Repeat("x", 100_000) + violations := validateJSONAgainstSchema([]byte(`{`+strconv.Quote(longName)+`:1}`), schema) + if len(violations) < 2 || violations[len(violations)-1].Keyword != "truncated" { + t.Fatalf("expected diagnostic-byte truncation, got %d violations", len(violations)) + } + totalBytes := 0 + for _, violation := range violations { + if len(violation.Path) > maxSchemaLocationBytes || len(violation.Keyword) > maxSchemaLocationBytes { + t.Fatalf("oversized diagnostic location: path=%d keyword=%d", len(violation.Path), len(violation.Keyword)) + } + totalBytes += len(violation.String()) + } + if totalBytes > maxSchemaDiagnosticBytes { + t.Fatalf("diagnostic bytes=%d, want <=%d", totalBytes, maxSchemaDiagnosticBytes) + } +} + +func TestValidateJSONAgainstSchemaBoundsStringByteWork(t *testing.T) { + schemaJSON := `{"allOf":[` + strings.Repeat(`{"minLength":0},`, 2_046) + `{"minLength":0}]}` + schema, err := parseArtifactSchema([]byte(schemaJSON)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(strconv.Quote(strings.Repeat("x", 10_000))), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" || !strings.Contains(violations[0].Message, "byte validation safety budget") { + t.Fatalf("expected string-byte work bound, got %v", violations) + } +} + +func TestValidateJSONAgainstSchemaBoundsRegexByteWork(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"pattern":` + strconv.Quote(strings.Repeat("a", 60_000)) + `}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(strconv.Quote(strings.Repeat("a", 1_000))), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" || !strings.Contains(violations[0].Message, "byte validation safety budget") { + t.Fatalf("expected regex byte-work bound, got %v", violations) + } +} + +func TestValidateJSONAgainstSchemaChargesExpandedRegexWork(t *testing.T) { + for _, tc := range []struct { + name string + schema string + doc string + }{ + {"pattern", `{"pattern":"(a?){1000}$"}`, strconv.Quote(strings.Repeat("a", 5_000))}, + {"patternProperties", `{"patternProperties":{"(a?){1000}$":{}}}`, `{` + strconv.Quote(strings.Repeat("a", 5_000)) + `:1}`}, + } { + t.Run(tc.name, func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(tc.schema)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(tc.doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" || !strings.Contains(violations[0].Message, "byte validation safety budget") { + t.Fatalf("expected expanded-regex work bound, got %v", violations) + } + }) + } +} + +func TestValidateJSONAgainstSchemaUsesECMA262RegexpSemantics(t *testing.T) { + dotSchema, err := parseArtifactSchema([]byte(`{"pattern":"^.$"}`)) + if err != nil { + t.Fatalf("dot schema parse failed: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"x"`), dotSchema); len(violations) != 0 { + t.Fatalf("ordinary character rejected: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`"\r"`), dotSchema); len(violations) != 1 { + t.Fatalf("ECMA line terminator accepted by dot: %v", violations) + } + + controlSchema, err := parseArtifactSchema([]byte(`{"pattern":"^\\cC$"}`)) + if err != nil { + t.Fatalf("ECMA control escape rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"\u0003"`), controlSchema); len(violations) != 0 { + t.Fatalf("ECMA control escape not enforced: %v", violations) + } + + unicodeSchema, err := parseArtifactSchema([]byte(`{"pattern":"^\\u{1F600}$"}`)) + if err != nil { + t.Fatalf("ECMA Unicode escape rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"😀"`), unicodeSchema); len(violations) != 0 { + t.Fatalf("ECMA Unicode escape not enforced: %v", violations) + } + surrogateSchema, err := parseArtifactSchema([]byte(`{"pattern":"^\\uD83D\\uDE00$"}`)) + if err != nil { + t.Fatalf("ECMA surrogate-pair escape rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"😀"`), surrogateSchema); len(violations) != 0 { + t.Fatalf("ECMA surrogate-pair escape not enforced: %v", violations) + } + + spaceSchema, err := parseArtifactSchema([]byte(`{"pattern":"^\\s$"}`)) + if err != nil { + t.Fatalf("ECMA whitespace class rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"\uFEFF"`), spaceSchema); len(violations) != 0 { + t.Fatalf("ECMA Unicode whitespace not enforced: %v", violations) + } + + anchorSchema, err := parseArtifactSchema([]byte(`{"pattern":"^a$"}`)) + if err != nil { + t.Fatalf("ECMA end anchor rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"a\n"`), anchorSchema); len(violations) != 1 { + t.Fatalf("ECMA strict end anchor accepted trailing newline: %v", violations) + } + + caretSchema, err := parseArtifactSchema([]byte(`{"pattern":"^[^^]$"}`)) + if err != nil { + t.Fatalf("ECMA negated-caret class rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"a"`), caretSchema); len(violations) != 0 { + t.Fatalf("ECMA negated-caret class rejected non-caret: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`"^"`), caretSchema); len(violations) != 1 { + t.Fatalf("ECMA negated-caret class accepted caret: %v", violations) + } + + for _, unsupported := range []string{`(?=a)`, `(a)\1`, `\p{L}`, `[]]`, `[^]`} { + _, err := parseArtifactSchema([]byte(`{"pattern":` + strconv.Quote(unsupported) + `}`)) + if err == nil || !strings.Contains(err.Error(), "not supported by bounded artifact validation") { + t.Fatalf("unsafe ECMA expression accepted: %q: %v", unsupported, err) + } + } +} + +func TestValidateJSONAgainstSchemaBoundsNumericWork(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","items":{"multipleOf":1}}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := `[` + strings.Repeat(`1e10000,`, 1_999) + `1e10000]` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" || !strings.Contains(violations[0].Message, "numeric validation safety budget") { + t.Fatalf("expected numeric-work bound, got %v", violations) + } +} + +func TestValidateJSONAgainstSchemaBoundsUniqueItemsCollisionWork(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","uniqueItems":true}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + var doc strings.Builder + doc.WriteByte('[') + for i := 0; i < 420; i++ { + if i > 0 { + doc.WriteByte(',') + } + doc.WriteString(strconv.Quote(strings.Repeat("x", 1_700) + strconv.Itoa(i))) + } + doc.WriteByte(']') + violations := validateJSONAgainstSchema([]byte(doc.String()), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" || !strings.Contains(violations[0].Message, "uniqueItems") { + t.Fatalf("expected uniqueItems collision-work bound, got %v", violations) + } +} + +func TestValidateJSONAgainstSchemaBoundsUniqueItemsNumericWork(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","uniqueItems":true}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := `[` + strings.Repeat(`1e10000,`, 3_999) + `2e10000]` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" || !strings.Contains(violations[0].Message, "numeric validation safety budget") { + t.Fatalf("expected uniqueItems numeric-work bound, got %v", violations) + } +} + +func TestValidateJSONAgainstSchemaBoundsDocumentValues(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","items":{"type":"integer"}}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := "[" + strings.Repeat("0,", maxSchemaArtifactValues) + "0]" + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || !strings.Contains(violations[0].Message, "100000-value validation limit") { + t.Fatalf("expected bounded-document violation, got %v", violations) + } +} + +func TestValidateJSONAgainstSchemaBoundsDocumentDepth(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"object"}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := strings.Repeat(`{"next":`, maxSchemaJSONDepth) + `{}` + strings.Repeat(`}`, maxSchemaJSONDepth) + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || !strings.Contains(violations[0].Message, "64-level nesting-depth limit") { + t.Fatalf("expected nesting-depth violation, got %v", violations) + } +} + +func TestArtifactSchemaBoundsJSONNumbers(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"number"}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`1e10001`), schema) + if len(violations) != 1 || violations[0].Message != "artifact JSON exceeds the numeric complexity limit" { + t.Fatalf("expected numeric-complexity violation, got %v", violations) + } + if _, err := parseArtifactSchema([]byte(`{"minimum":1e10001}`)); err == nil || !strings.Contains(err.Error(), "exponent limit") { + t.Fatalf("expected schema numeric-complexity error, got %v", err) + } +} + +func TestParseArtifactSchemaBoundsImplementationWork(t *testing.T) { + t.Run("cardinality overflow is rejected", func(t *testing.T) { + for _, keyword := range []string{"minItems", "maxItems", "minLength", "maxLength", "minProperties", "maxProperties", "minContains", "maxContains"} { + _, err := parseArtifactSchema([]byte(`{"` + keyword + `":2147483648}`)) + if err == nil || !strings.Contains(err.Error(), "cardinality limit") { + t.Fatalf("expected %s overflow to be rejected, got %v", keyword, err) + } + } + }) + + t.Run("subschema fanout is rejected before compile", func(t *testing.T) { + schema := `{"allOf":[` + strings.Repeat(`{},`, maxSchemaSubschemas) + `{}` + `]}` + _, err := parseArtifactSchema([]byte(schema)) + if err == nil || !strings.Contains(err.Error(), "subschema limit") { + t.Fatalf("expected schema fanout to be bounded, got %v", err) + } + }) + + t.Run("total schema values are bounded before compile", func(t *testing.T) { + schema := `{"enum":[` + strings.Repeat(`0,`, maxSchemaDefinitionValues) + `0]}` + _, err := parseArtifactSchema([]byte(schema)) + if err == nil || !strings.Contains(err.Error(), "more than 4096 values") { + t.Fatalf("expected schema values to be bounded, got %v", err) + } + }) + + t.Run("invalid subschema is rejected before meta-validation", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{"allOf":[0]}`)) + if err == nil || !strings.Contains(err.Error(), "subschema must be an object or boolean") { + t.Fatalf("expected invalid subschema to fail early, got %v", err) + } + }) + + t.Run("schema-like data inside const is not treated as a subschema", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"const":{"allOf":[0]}}`)) + if err != nil { + t.Fatalf("schema data rejected as subschema: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`{"allOf":[0]}`), schema); len(violations) != 0 { + t.Fatalf("matching const rejected: %v", violations) + } + }) + + t.Run("schema and artifact work product is bounded", func(t *testing.T) { + schemaJSON := `{"allOf":[` + strings.Repeat(`{},`, 100) + `{}` + `]}` + schema, err := parseArtifactSchema([]byte(schemaJSON)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := `[` + strings.Repeat(`0,`, 999) + `0]` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" { + t.Fatalf("expected validation-work bound, got %v", violations) + } + }) + + t.Run("arbitrary local ref target contributes full document weight", func(t *testing.T) { + schemaJSON := `{"$ref":"#/hidden","hidden":{"allOf":[` + strings.Repeat(`{"type":"integer"},`, 100) + `{"type":"integer"}]}}` + schema, err := parseArtifactSchema([]byte(schemaJSON)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := `[` + strings.Repeat(`0,`, 999) + `0]` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" { + t.Fatalf("expected local-ref work bound, got %v", violations) + } + }) + + t.Run("reference fanout is expanded before validation", func(t *testing.T) { + var schemaJSON strings.Builder + schemaJSON.WriteString(`{"$ref":"#/$defs/d0","$defs":{`) + for i := 0; i < 20; i++ { + if i > 0 { + schemaJSON.WriteByte(',') + } + schemaJSON.WriteString(strconv.Quote("d" + strconv.Itoa(i))) + schemaJSON.WriteString(`:{"allOf":[{"$ref":"#/$defs/d`) + schemaJSON.WriteString(strconv.Itoa(i + 1)) + schemaJSON.WriteString(`"},{"$ref":"#/$defs/d`) + schemaJSON.WriteString(strconv.Itoa(i + 1)) + schemaJSON.WriteString(`"}]}`) + } + schemaJSON.WriteString(`,"d20":{"type":"integer"}}}`) + if _, err := parseArtifactSchema([]byte(schemaJSON.String())); err == nil || !strings.Contains(err.Error(), "expanded validation safety budget") { + t.Fatalf("reference fanout accepted: %v", err) + } + }) + + t.Run("recursive reference cycles fail preflight", func(t *testing.T) { + schemaJSON := `{ + "$defs":{"node":{ + "type":"object", + "properties":{"next":{"allOf":[ + {"$ref":"#/$defs/node"}, + {"$ref":"#/$defs/node"} + ]}} + }}, + "$ref":"#/$defs/node" + }` + if _, err := parseArtifactSchema([]byte(schemaJSON)); err == nil || !strings.Contains(err.Error(), "recursive schema references") { + t.Fatalf("recursive reference cycle accepted: %v", err) + } + }) + + t.Run("reference shaped literal data is not schema work", func(t *testing.T) { + literal := strings.Repeat(`{"$ref":"literal"},`, 224) + `{"$ref":"literal"}` + if _, err := parseArtifactSchema([]byte(`{"const":[` + literal + `]}`)); err != nil { + t.Fatalf("reference-shaped const data rejected: %v", err) + } + }) + + t.Run("referenced annotation target receives cardinality bounds", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{ + "$ref":"#/definitions/x", + "definitions":{"x":{"type":"array","minItems":9223372036854775808}} + }`)) + if err == nil || !strings.Contains(err.Error(), "cardinality limit") { + t.Fatalf("oversized referenced cardinality accepted: %v", err) + } + }) + + t.Run("referenced nested resource target receives cardinality bounds", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{ + "properties":{"nested":{ + "$id":"nested.json", + "$ref":"#/hidden", + "hidden":{"type":"array","minItems":18446744073709551616} + }} + }`)) + if err == nil || !strings.Contains(err.Error(), "cardinality limit") { + t.Fatalf("oversized nested-resource cardinality accepted: %v", err) + } + }) + + t.Run("static anchor references remain supported", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$defs":{"value":{"$anchor":"value","type":"string"}}, + "$ref":"#value" + }`)) + if err != nil { + t.Fatalf("static anchor reference rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`1`), schema); len(violations) != 1 { + t.Fatalf("static anchor reference not enforced: %v", violations) + } + }) + + t.Run("local reference registers hidden embedded resource", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$ref":"#/hidden", + "hidden":{"$id":"nested.json","type":"string"} + }`)) + if err != nil { + t.Fatalf("hidden embedded resource rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`1`), schema); len(violations) != 1 || violations[0].Keyword != "/hidden/type" { + t.Fatalf("hidden embedded resource not enforced: %v", violations) + } + }) + + t.Run("local reference preserves embedded resource draft", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$defs":{"legacy":{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"legacy.json", + "hidden":{"items":[{"type":"string"}]} + }}, + "$ref":"legacy.json#/hidden" + }`)) + if err != nil { + t.Fatalf("mixed-draft local reference rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`["ok"]`), schema); len(violations) != 0 { + t.Fatalf("valid draft-07 tuple rejected: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`[1]`), schema); len(violations) != 1 { + t.Fatalf("invalid draft-07 tuple accepted: %v", violations) + } + }) + + t.Run("embedded resource draft does not replace enclosing draft", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$defs":{"legacy":{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"legacy.json" + }}, + "hidden":{"prefixItems":[{"$id":"nested.json","type":"string"}]}, + "$ref":"#/hidden" + }`)) + if err != nil { + t.Fatalf("mixed-draft enclosing reference rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`["ok"]`), schema); len(violations) != 0 { + t.Fatalf("valid enclosing-draft tuple rejected: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`[1]`), schema); len(violations) != 1 { + t.Fatalf("enclosing draft was not preserved: %v", violations) + } + }) + + t.Run("draft-07 ref ignores sibling constraints", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"http://json-schema.org/draft-07/schema#", + "definitions":{"target":{"type":"string"}}, + "$ref":"#/definitions/target", + "maxItems":2147483648, + "if":{"$ref":"#"} + }`)) + if err != nil { + t.Fatalf("ignored draft-07 ref siblings rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"ok"`), schema); len(violations) != 0 { + t.Fatalf("valid referenced value rejected: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`1`), schema); len(violations) != 1 { + t.Fatalf("draft-07 reference target not enforced: %v", violations) + } + }) + + t.Run("draft-2020 ref retains sibling constraints", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$ref":"#/$defs/target", + "$defs":{"target":{}}, + "maxItems":2147483648 + }`)) + if err == nil || !strings.Contains(err.Error(), "cardinality limit") { + t.Fatalf("active draft-2020 ref sibling accepted: %v", err) + } + }) + + t.Run("unreferenced instance data does not receive cardinality bounds", func(t *testing.T) { + if _, err := parseArtifactSchema([]byte(`{"const":{"minItems":9223372036854775808}}`)); err != nil { + t.Fatalf("instance data was treated as a schema: %v", err) + } + }) + + t.Run("runtime rebound references fail preflight", func(t *testing.T) { + cases := []string{ + `{"$schema":"https://json-schema.org/draft/2020-12/schema","$dynamicAnchor":"node","$dynamicRef":"#node"}`, + `{"$schema":"https://json-schema.org/draft/2019-09/schema","$recursiveAnchor":true,"$recursiveRef":"#"}`, + } + for _, schemaJSON := range cases { + if _, err := parseArtifactSchema([]byte(schemaJSON)); err == nil || !strings.Contains(err.Error(), "not supported by bounded artifact validation") { + t.Fatalf("runtime-rebound reference accepted for %s: %v", schemaJSON, err) + } + } + }) + + t.Run("unique items equality work is bounded", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","uniqueItems":true}`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := `[` + strings.Repeat(`0,`, 4_999) + `1]` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" { + t.Fatalf("expected uniqueItems work bound, got %v", violations) + } + }) + + t.Run("aggregate schema numeric work fails before compilation", func(t *testing.T) { + schemaJSON := `{"allOf":[` + strings.Repeat(`{"minimum":1e10000},`, 2_046) + `{"minimum":1e10000}]}` + if _, err := parseArtifactSchema([]byte(schemaJSON)); err == nil || !strings.Contains(err.Error(), "numeric compilation safety budget") { + t.Fatalf("aggregate schema numeric work accepted: %v", err) + } + }) + + t.Run("schema object names bound compiled location growth", func(t *testing.T) { + name := strings.Repeat("x", maxSchemaObjectNameBytes+1) + _, err := parseArtifactSchema([]byte(`{"properties":{` + strconv.Quote(name) + `:{}}}`)) + if err == nil || !strings.Contains(err.Error(), "object name") { + t.Fatalf("oversized schema object name accepted: %v", err) + } + }) + + t.Run("schema resource URL length is bounded", func(t *testing.T) { + id := "https://example.invalid/" + strings.Repeat("x", maxSchemaResourceURLBytes) + _, err := parseArtifactSchema([]byte(`{"$id":` + strconv.Quote(id) + `}`)) + if err == nil || !strings.Contains(err.Error(), "resource ID") { + t.Fatalf("oversized schema resource ID accepted: %v", err) + } + }) + + t.Run("schema resource URL aggregate is bounded", func(t *testing.T) { + rootID := "https://example.invalid/" + strings.Repeat("x", 1_700) + "/" + var schemaJSON strings.Builder + schemaJSON.WriteString(`{"$id":`) + schemaJSON.WriteString(strconv.Quote(rootID)) + schemaJSON.WriteString(`,"allOf":[`) + for i := 0; i < 700; i++ { + if i > 0 { + schemaJSON.WriteByte(',') + } + schemaJSON.WriteString(`{"$id":`) + schemaJSON.WriteString(strconv.Quote("resource-" + strconv.Itoa(i))) + schemaJSON.WriteByte('}') + } + schemaJSON.WriteString(`]}`) + _, err := parseArtifactSchema([]byte(schemaJSON.String())) + if err == nil || !strings.Contains(err.Error(), "aggregate limit") { + t.Fatalf("oversized aggregate schema resource URLs accepted: %v", err) + } + }) + + t.Run("schema reference URL length is bounded", func(t *testing.T) { + reference := "https://example.invalid/" + strings.Repeat("x", maxSchemaResourceURLBytes) + _, err := parseArtifactSchema([]byte(`{"$ref":` + strconv.Quote(reference) + `}`)) + if err == nil || !strings.Contains(err.Error(), "$ref URL") || strings.Contains(err.Error(), reference) { + t.Fatalf("oversized schema reference URL accepted or echoed: %v", err) + } + }) + + t.Run("schema reference URL aggregate is bounded", func(t *testing.T) { + rootID := "https://example.invalid/" + strings.Repeat("x", 1_700) + "/" + var schemaJSON strings.Builder + schemaJSON.WriteString(`{"$id":`) + schemaJSON.WriteString(strconv.Quote(rootID)) + schemaJSON.WriteString(`,"allOf":[`) + for i := 0; i < 700; i++ { + if i > 0 { + schemaJSON.WriteByte(',') + } + schemaJSON.WriteString(`{"$ref":"target"}`) + } + schemaJSON.WriteString(`]}`) + _, err := parseArtifactSchema([]byte(schemaJSON.String())) + if err == nil || !strings.Contains(err.Error(), "reference URLs exceed") { + t.Fatalf("oversized aggregate schema reference URLs accepted: %v", err) + } + }) + + t.Run("required name bytes contribute validation weight", func(t *testing.T) { + name := strings.Repeat("x", maxSchemaValidationWork) + _, err := parseArtifactSchema([]byte(`{"required":[` + strconv.Quote(name) + `]}`)) + if err == nil || !strings.Contains(err.Error(), "validation safety budget") { + t.Fatalf("oversized required operand accepted: %v", err) + } + }) + + t.Run("regular expression program expansion is bounded", func(t *testing.T) { + if _, err := parseArtifactSchema([]byte(`{"pattern":"(a?){1000}"}`)); err != nil { + t.Fatalf("bounded repeated expression rejected: %v", err) + } + expression := strings.Repeat(`(a?){1000}`, 30) + _, err := parseArtifactSchema([]byte(`{"pattern":` + strconv.Quote(expression) + `}`)) + if err == nil || !strings.Contains(err.Error(), "compiled-program safety limit") { + t.Fatalf("expanded regular expression accepted: %v", err) + } + }) + + t.Run("regular expression aggregate compilation is bounded", func(t *testing.T) { + expression := `(a?){1000}` + var schemaJSON strings.Builder + schemaJSON.WriteString(`{"allOf":[`) + for i := 0; i < 300; i++ { + if i > 0 { + schemaJSON.WriteByte(',') + } + schemaJSON.WriteString(`{"pattern":`) + schemaJSON.WriteString(strconv.Quote(expression)) + schemaJSON.WriteByte('}') + } + schemaJSON.WriteString(`]}`) + _, err := parseArtifactSchema([]byte(schemaJSON.String())) + if err == nil || !strings.Contains(err.Error(), "aggregate compilation safety budget") { + t.Fatalf("aggregate regular expression work accepted: %v", err) + } + }) + + t.Run("artifact-controlled regex format is not asserted", func(t *testing.T) { + _, err := parseArtifactSchema([]byte(`{ + "$schema":"http://json-schema.org/draft-07/schema#", + "format":"regex" + }`)) + if err == nil || !strings.Contains(err.Error(), `format "regex" is not supported`) { + t.Fatalf("asserted legacy regex format accepted: %v", err) + } + _, err = parseArtifactSchema([]byte(`{ + "$schema":"http://json-schema.org/draft-07/schema#", + "definitions":{"target":{"$id":"#target","format":"regex"}}, + "$ref":"#target" + }`)) + if err == nil || !strings.Contains(err.Error(), `format "regex" is not supported`) { + t.Fatalf("static-anchor regex format accepted: %v", err) + } + + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "format":"regex" + }`)) + if err != nil { + t.Fatalf("modern regex annotation rejected: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`"["`), schema); len(violations) != 0 { + t.Fatalf("modern regex annotation unexpectedly asserted: %v", violations) + } + }) + + t.Run("referenced target obeys compiled subschema limit", func(t *testing.T) { + schemaJSON := `{"$ref":"#/hidden","hidden":{"allOf":[` + strings.Repeat(`{},`, maxSchemaSubschemas) + `{ }]}}` + _, err := parseArtifactSchema([]byte(schemaJSON)) + if err == nil || !strings.Contains(err.Error(), "2048-subschema") { + t.Fatalf("referenced oversized compiled graph accepted: %v", err) + } + }) + + t.Run("empty dependency entries still consume validation work", func(t *testing.T) { + var schemaJSON strings.Builder + schemaJSON.WriteString(`{"type":"array","items":{"type":"object","dependentRequired":{`) + for i := 0; i < 1_000; i++ { + if i > 0 { + schemaJSON.WriteByte(',') + } + schemaJSON.WriteString(strconv.Quote("p" + strconv.Itoa(i))) + schemaJSON.WriteString(`:[]`) + } + schemaJSON.WriteString(`}}}`) + schema, err := parseArtifactSchema([]byte(schemaJSON.String())) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + doc := `[` + strings.Repeat(`{},`, 99) + `{}` + `]` + violations := validateJSONAgainstSchema([]byte(doc), schema) + if len(violations) != 1 || violations[0].Keyword != "complexity" { + t.Fatalf("expected dependency work bound, got %v", violations) + } + }) + + t.Run("inactive keywords retain annotation semantics", func(t *testing.T) { + cases := []struct { + schema string + doc string + }{ + {`{"$schema":"http://json-schema.org/draft-07/schema#","prefixItems":0}`, `null`}, + {`{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalItems":0}`, `null`}, + {`{"$schema":"http://json-schema.org/draft-06/schema#","if":0}`, `null`}, + {`{"$schema":"https://json-schema.org/draft/2020-12/schema","dependencies":{"x":["y"]}}`, `{"x":1}`}, + } + for _, tc := range cases { + schema, err := parseArtifactSchema([]byte(tc.schema)) + if err != nil { + t.Fatalf("inactive keyword rejected for %s: %v", tc.schema, err) + } + if violations := validateJSONAgainstSchema([]byte(tc.doc), schema); len(violations) != 0 { + t.Fatalf("inactive keyword unexpectedly enforced for %s: %v", tc.schema, violations) + } + } + }) + + t.Run("inactive dependencies content does not trigger a schema load", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "dependencies":{"note":{"$schema":"https://invalid.example/schema"}} + }`)) + if err != nil { + t.Fatalf("inactive dependencies content was compiled: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`{"note":1}`), schema); len(violations) != 0 { + t.Fatalf("inactive dependencies content was enforced: %v", violations) + } + }) + + t.Run("legacy dependencies remain referenceable annotations", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "dependencies":{"legacy":{"type":"string"}}, + "$ref":"#/dependencies/legacy" + }`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`1`), schema) + if len(violations) != 1 || violations[0].Keyword != "/dependencies/legacy/type" { + t.Fatalf("expected referenced annotation schema to remain active, got %v", violations) + } + }) + + t.Run("embedded older draft resource retains dependencies", func(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "properties":{"legacy":{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"legacy.json", + "dependencies":{"x":["y"]} + }} + }`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`{"legacy":{"x":1}}`), schema) + if len(violations) != 1 || violations[0].Keyword != "/properties/legacy/dependency/x" { + t.Fatalf("expected embedded draft-07 dependency violation, got %v", violations) + } + }) + + t.Run("active keyword shapes remain fail closed", func(t *testing.T) { + cases := []string{ + `{"$schema":"https://json-schema.org/draft/2020-12/schema","prefixItems":0}`, + `{"$schema":"https://json-schema.org/draft/2020-12/schema","items":[]}`, + `{"$schema":"http://json-schema.org/draft-07/schema#","additionalItems":0}`, + } + for _, schemaJSON := range cases { + if _, err := parseArtifactSchema([]byte(schemaJSON)); err == nil { + t.Fatalf("active invalid keyword shape accepted: %s", schemaJSON) + } + } + }) +} + +func TestValidateJSONAgainstDraft7AdditionalItemsReportsAbsoluteIndex(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{ + "$schema":"http://json-schema.org/draft-07/schema#", + "items":[{"type":"string"},{"type":"string"}], + "additionalItems":{"type":"integer"} + }`)) + if err != nil { + t.Fatalf("schema parse failed: %v", err) + } + violations := validateJSONAgainstSchema([]byte(`["a","b","wrong"]`), schema) + if len(violations) != 1 || violations[0].Path != "/2" { + t.Fatalf("expected absolute additionalItems path /2, got %v", violations) + } +} + func TestParseRequireArtifactSchemaSpec(t *testing.T) { t.Run("valid spec", func(t *testing.T) { remote, schema, err := parseRequireArtifactSchemaSpec("reports/out.json=schema.json") @@ -361,31 +1161,74 @@ func TestLoadRequireArtifactSchemas(t *testing.T) { _, err := loadRequireArtifactSchemas([]string{"out.json=" + good, "out.json=" + good}) assertExitCode(t, err, 2) }) + + t.Run("oversized schema is exit 2", func(t *testing.T) { + oversized := filepath.Join(dir, "oversized.schema.json") + if err := os.WriteFile(oversized, bytes.Repeat([]byte(" "), maxSchemaDefinitionBytes+1), 0o600); err != nil { + t.Fatalf("write oversized schema: %v", err) + } + _, err := loadRequireArtifactSchemas([]string{"out.json=" + oversized}) + assertExitCode(t, err, 2) + if !strings.Contains(err.Error(), "1048576-byte limit") { + t.Fatalf("expected bounded-schema diagnostic, got %v", err) + } + }) } -func TestParseArtifactSchemaFailsClosedOnUnsupportedKeywords(t *testing.T) { +func TestParseArtifactSchemaSupportsStandardKeywords(t *testing.T) { cases := []struct { - name string - schema string + name string + schema string + valid string + invalid string }{ - {"pattern", `{"type":"string","pattern":"^x"}`}, - {"minimum", `{"type":"number","minimum":0}`}, - {"additionalProperties", `{"type":"object","additionalProperties":false}`}, - {"anyOf", `{"anyOf":[{"type":"string"}]}`}, - {"ref", `{"$ref":"#/definitions/x"}`}, - {"nested unsupported keyword", `{"type":"object","properties":{"x":{"type":"string","maxLength":3}}}`}, + {"pattern", `{"type":"string","pattern":"^x"}`, `"xyz"`, `"no"`}, + {"minimum", `{"type":"number","minimum":0}`, `0`, `-1`}, + {"additionalProperties", `{"type":"object","additionalProperties":false}`, `{}`, `{"x":1}`}, + {"anyOf", `{"anyOf":[{"type":"string"},{"type":"number"}]}`, `1`, `false`}, + {"local ref", `{"$defs":{"x":{"type":"string","maxLength":3}},"$ref":"#/$defs/x"}`, `"abc"`, `"long"`}, + {"nested maxLength", `{"type":"object","properties":{"x":{"type":"string","maxLength":3}}}`, `{"x":"abc"}`, `{"x":"long"}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if _, err := parseArtifactSchema([]byte(tc.schema)); err == nil { - t.Fatalf("expected %s schema to be rejected fail-closed", tc.name) + schema, err := parseArtifactSchema([]byte(tc.schema)) + if err != nil { + t.Fatalf("compile standard schema: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(tc.valid), schema); len(violations) != 0 { + t.Fatalf("valid document rejected: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(tc.invalid), schema); len(violations) == 0 { + t.Fatalf("invalid document accepted") } }) } } +func TestParseArtifactSchemaDefaultsToDraft2020(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"array","prefixItems":[{"type":"string"}],"items":false}`)) + if err != nil { + t.Fatalf("compile draft 2020-12 schema: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`["x"]`), schema); len(violations) != 0 { + t.Fatalf("draft 2020-12 prefixItems rejected: %v", violations) + } + if violations := validateJSONAgainstSchema([]byte(`["x",1]`), schema); len(violations) == 0 { + t.Fatalf("draft 2020-12 items=false was not enforced") + } +} + +func TestParseArtifactSchemaRejectsExternalReferences(t *testing.T) { + for _, ref := range []string{"https://example.com/schema.json", "other.json"} { + _, err := parseArtifactSchema([]byte(`{"$ref":` + strconv.Quote(ref) + `}`)) + if err == nil || !strings.Contains(err.Error(), "external schema reference") { + t.Fatalf("expected external reference %q to fail closed, got %v", ref, err) + } + } +} + func TestParseArtifactSchemaAcceptsAnnotationKeywords(t *testing.T) { - data := []byte(`{"$schema":"x","$id":"y","title":"t","description":"d","$comment":"c","examples":[1],"default":1,"deprecated":false,"type":"object","required":["a"]}`) + data := []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"urn:crabbox:test","title":"t","description":"d","$comment":"c","examples":[1],"default":1,"deprecated":false,"type":"object","required":["a"]}`) if _, err := parseArtifactSchema(data); err != nil { t.Fatalf("annotation keywords should be accepted, got: %v", err) } From 4d4294880c24e4eb37cd474ce50a2863d2bb5af0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 04:07:42 -0700 Subject: [PATCH 13/13] fix(cli): reject invalid UTF-8 artifacts Co-authored-by: Dwin Gharibi --- internal/cli/run_artifact_schema.go | 6 ++++++ internal/cli/run_artifact_schema_test.go | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/cli/run_artifact_schema.go b/internal/cli/run_artifact_schema.go index 3a113162b..4019c12f8 100644 --- a/internal/cli/run_artifact_schema.go +++ b/internal/cli/run_artifact_schema.go @@ -105,6 +105,9 @@ func (rejectingSchemaLoader) Load(rawURL string) (any, error) { } func parseArtifactSchema(data []byte) (*artifactSchema, error) { + if !utf8.Valid(data) { + return nil, fmt.Errorf("schema is not valid UTF-8") + } schemaStats, err := scanJSONDocument(data, maxSchemaDefinitionValues, maxSchemaJSONDepth) if err != nil { return nil, err @@ -1437,6 +1440,9 @@ func requireJSONDecoderEOF(decoder *json.Decoder) error { } func validateJSONAgainstSchema(doc []byte, schema *artifactSchema) []schemaViolation { + if !utf8.Valid(doc) { + return []schemaViolation{{Keyword: "json", Message: "artifact is not valid UTF-8"}} + } stats, err := validateArtifactJSONShape(doc) if err != nil { message := "artifact is not valid unambiguous JSON" diff --git a/internal/cli/run_artifact_schema_test.go b/internal/cli/run_artifact_schema_test.go index ab18adcff..19d30fc47 100644 --- a/internal/cli/run_artifact_schema_test.go +++ b/internal/cli/run_artifact_schema_test.go @@ -84,7 +84,6 @@ func TestParseArtifactSchemaRejectsInvalidKeywordShapes(t *testing.T) { {"duplicate root keyword", `{"type":"object","type":"string"}`}, {"duplicate nested keyword", `{"properties":{"x":{"required":["a"],"required":[]}}}`}, {"duplicate property schema", `{"properties":{"x":{"type":"string"},"x":{"type":"number"}}}`}, - {"mis-cased required cannot override required", `{"type":"object","required":["proof"],"REQUIRED":[]}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -95,6 +94,16 @@ func TestParseArtifactSchemaRejectsInvalidKeywordShapes(t *testing.T) { } } +func TestParseArtifactSchemaMisCasedKeywordCannotOverrideRequired(t *testing.T) { + schema, err := parseArtifactSchema([]byte(`{"type":"object","required":["proof"],"REQUIRED":[]}`)) + if err != nil { + t.Fatalf("standard JSON Schema extension keyword should compile: %v", err) + } + if violations := validateJSONAgainstSchema([]byte(`{}`), schema); len(violations) == 0 { + t.Fatal("mis-cased extension keyword unexpectedly disabled required validation") + } +} + func TestValidateJSONAgainstSchema(t *testing.T) { schema, err := parseArtifactSchema([]byte(`{ "type": "object",