diff --git a/internal/prinfo/schemas/generate.go b/internal/prinfo/schemas/generate.go deleted file mode 100644 index c4267bd47..000000000 --- a/internal/prinfo/schemas/generate.go +++ /dev/null @@ -1,45 +0,0 @@ -// -// Copyright 2025-2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/chainloop-dev/chainloop/internal/prinfo" -) - -//go:generate go run ./generate.go --output-dir ../../../internal/schemavalidators/internal_schemas/prinfo --version 1.2 -func main() { - var outputDir string - var version string - - flag.StringVar(&outputDir, "output-dir", "../../../internal/schemavalidators/internal_schemas/prinfo", "Directory to output the schema files") - flag.StringVar(&version, "version", "1.2", "Schema version") - flag.Parse() - - generator := prinfo.NewGenerator() - - fmt.Printf("Generating JSON schema for PR/MR Info\n") - sch := generator.GeneratePRInfoSchema(version) - if err := generator.Save(sch, outputDir, version); err != nil { - fmt.Fprintf(os.Stderr, "Error writing schema: %v\n", err) - os.Exit(1) - } - - fmt.Printf("JSON schema successfully generated at %s/pr-info-%s.schema.json\n", outputDir, version) -} diff --git a/internal/schemavalidators/schemavalidators.go b/internal/schemavalidators/schemavalidators.go index 3e410a188..3a234e92d 100644 --- a/internal/schemavalidators/schemavalidators.go +++ b/internal/schemavalidators/schemavalidators.go @@ -45,9 +45,6 @@ type AsyncAPIVersion string // RunnerContextVersion represents the version of Runner Context schema. type RunnerContextVersion string -// PRInfoVersion represents the version of PR/MR Info schema. -type PRInfoVersion string - // AIAgentConfigVersion represents the version of AI Agent Config schema. type AIAgentConfigVersion string @@ -62,14 +59,6 @@ type ScorecardVersion string const ( // RunnerContextVersion0_1 represents Runner Context version 0.1 schema. RunnerContextVersion0_1 RunnerContextVersion = "0.1" - // PRInfoVersion1_0 represents PR/MR Info version 1.0 schema. - PRInfoVersion1_0 PRInfoVersion = "1.0" - // PRInfoVersion1_1 represents PR/MR Info version 1.1 schema (adds reviewers). - PRInfoVersion1_1 PRInfoVersion = "1.1" - // PRInfoVersion1_2 represents PR/MR Info version 1.2 schema (adds requested and review_status to reviewers). - PRInfoVersion1_2 PRInfoVersion = "1.2" - // PRInfoVersion1_3 represents PR/MR Info version 1.3 schema (author as object with type). - PRInfoVersion1_3 PRInfoVersion = "1.3" // CycloneDXVersion1_5 represents CycloneDX version 1.5 schema. CycloneDXVersion1_5 CycloneDXVersion = "1.5" // CycloneDXVersion1_6 represents CycloneDX version 1.6 schema. @@ -129,16 +118,6 @@ var ( //go:embed internal_schemas/runnercontext/runner-context-response-0.1.schema.json runnerContextSpecVersion0_1 string - // PR/MR Info schemas - //go:embed internal_schemas/prinfo/pr-info-1.0.schema.json - prInfoSpecVersion1_0 string - //go:embed internal_schemas/prinfo/pr-info-1.1.schema.json - prInfoSpecVersion1_1 string - //go:embed internal_schemas/prinfo/pr-info-1.2.schema.json - prInfoSpecVersion1_2 string - //go:embed internal_schemas/prinfo/pr-info-1.3.schema.json - prInfoSpecVersion1_3 string - // AI Agent Config schemas //go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json aiAgentConfigSpecVersion0_1 string @@ -181,8 +160,6 @@ var ( csafOnce sync.Once compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema runnerContextOnce sync.Once - compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema - prInfoOnce sync.Once compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema aiAgentConfigOnce sync.Once compiledAICodingSessionSchemas map[AICodingSessionVersion]*jsonschema.Schema @@ -258,29 +235,6 @@ func initRunnerContextSchemas() { } } -func initPRInfoSchemas() { - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_0)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json", err)) - } - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_1)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json", err)) - } - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_2)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json", err)) - } - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_3)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json", err)) - } - - compiledPRInfoSchemas = map[PRInfoVersion]*jsonschema.Schema{ - PRInfoVersion1_0: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json"), - PRInfoVersion1_1: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json"), - PRInfoVersion1_2: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json"), - PRInfoVersion1_3: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json"), - } -} - func initAIAgentConfigSchemas() { compiler := jsonschema.NewCompiler() if err := compiler.AddResource("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json", strings.NewReader(aiAgentConfigSpecVersion0_1)); err != nil { @@ -415,31 +369,6 @@ func ValidateChainloopRunnerContext(data interface{}, version RunnerContextVersi return nil } -// ValidatePRInfo validates the PR/MR info schema. -func ValidatePRInfo(data interface{}, version PRInfoVersion) error { - prInfoOnce.Do(initPRInfoSchemas) - - if version == "" { - version = PRInfoVersion1_3 - } - - schema, ok := compiledPRInfoSchemas[version] - if !ok { - return errors.New("invalid PR info schema version") - } - - if err := schema.Validate(data); err != nil { - var invalidJSONTypeError jsonschema.InvalidJSONTypeError - if errors.As(err, &invalidJSONTypeError) { - return ErrInvalidJSONPayload - } - return err - } - - return nil -} - -// ValidateAIAgentConfig validates the AI agent config schema. func ValidateAIAgentConfig(data any, version AIAgentConfigVersion) error { aiAgentConfigOnce.Do(initAIAgentConfigSchemas) diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 432dd9d79..f04c28b8c 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -235,47 +235,6 @@ func TestValidateRunnerContext(t *testing.T) { } } -func TestValidatePRInfo(t *testing.T) { - testCases := []struct { - name string - filePath string - wantErr string - }{ - { - name: "valid PR info with all fields", - filePath: "./testdata/pr_info_valid.json", - }, - { - name: "missing required fields", - filePath: "./testdata/pr_info_missing_required.json", - wantErr: "missing properties", - }, - { - name: "completely wrong format", - filePath: "./testdata/sbom-spdx.json", - wantErr: "missing properties", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - f, err := os.ReadFile(tc.filePath) - require.NoError(t, err) - - var v any - require.NoError(t, json.Unmarshal(f, &v)) - - err = schemavalidators.ValidatePRInfo(v, "") - if tc.wantErr != "" { - require.ErrorContains(t, err, tc.wantErr) - return - } - - require.NoError(t, err) - }) - } -} - func TestValidateAICodingSession(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/collector_prmetadata.go b/pkg/attestation/crafter/collector_prmetadata.go index 8426a2479..2df29b677 100644 --- a/pkg/attestation/crafter/collector_prmetadata.go +++ b/pkg/attestation/crafter/collector_prmetadata.go @@ -22,8 +22,8 @@ import ( "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/chainloop-dev/chainloop/pkg/prinfo" ) // PRMetadataCollector collects pull/merge request metadata from the CI environment. diff --git a/pkg/attestation/crafter/materials/chainloop_pr_info.go b/pkg/attestation/crafter/materials/chainloop_pr_info.go index e5a2c421d..275052d39 100644 --- a/pkg/attestation/crafter/materials/chainloop_pr_info.go +++ b/pkg/attestation/crafter/materials/chainloop_pr_info.go @@ -22,10 +22,9 @@ import ( "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" - "github.com/chainloop-dev/chainloop/internal/schemavalidators" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/chainloop-dev/chainloop/pkg/prinfo" "github.com/rs/zerolog" ) @@ -75,7 +74,7 @@ func (i *ChainloopPRInfoCrafter) Craft(ctx context.Context, artifactPath string) } // Validate the data against JSON schema - if err := schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3); err != nil { + if err := prinfo.Validate(rawData, prinfo.LatestVersion); err != nil { i.logger.Debug().Err(err).Msg("schema validation failed") return nil, fmt.Errorf("PR info validation failed: %w", err) } diff --git a/pkg/attestation/crafter/materials/chainloop_pr_info_test.go b/pkg/attestation/crafter/materials/chainloop_pr_info_test.go index 8e433819e..31144270e 100644 --- a/pkg/attestation/crafter/materials/chainloop_pr_info_test.go +++ b/pkg/attestation/crafter/materials/chainloop_pr_info_test.go @@ -22,8 +22,7 @@ import ( "testing" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" - "github.com/chainloop-dev/chainloop/internal/schemavalidators" + "github.com/chainloop-dev/chainloop/pkg/prinfo" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -118,7 +117,7 @@ func TestChainloopPRInfoCrafter_Validation(t *testing.T) { require.NoError(t, err) // Validate the data against JSON schema - err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3) + err = prinfo.Validate(rawData, prinfo.LatestVersion) if tc.wantErr { require.Error(t, err) @@ -143,7 +142,7 @@ func TestChainloopPRInfoCrafter_BackwardCompat(t *testing.T) { err := json.Unmarshal([]byte(oldFormatJSON), &rawData) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3) + err = prinfo.Validate(rawData, prinfo.LatestVersion) require.NoError(t, err) // New object author format should also validate against v1.3 @@ -158,7 +157,7 @@ func TestChainloopPRInfoCrafter_BackwardCompat(t *testing.T) { err = json.Unmarshal([]byte(newFormatJSON), &rawData) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3) + err = prinfo.Validate(rawData, prinfo.LatestVersion) require.NoError(t, err) } diff --git a/pkg/attestation/crafter/prmetadata.go b/pkg/attestation/crafter/prmetadata.go index 5b62702d3..1c500490e 100644 --- a/pkg/attestation/crafter/prmetadata.go +++ b/pkg/attestation/crafter/prmetadata.go @@ -26,7 +26,7 @@ import ( "time" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" + "github.com/chainloop-dev/chainloop/pkg/prinfo" ) // PRMetadata holds extracted PR/MR information diff --git a/pkg/attestation/crafter/prmetadata_test.go b/pkg/attestation/crafter/prmetadata_test.go index 017996a91..17f9b5ed8 100644 --- a/pkg/attestation/crafter/prmetadata_test.go +++ b/pkg/attestation/crafter/prmetadata_test.go @@ -24,7 +24,7 @@ import ( "path/filepath" "testing" - "github.com/chainloop-dev/chainloop/internal/prinfo" + "github.com/chainloop-dev/chainloop/pkg/prinfo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/prinfo/generator.go b/pkg/prinfo/generator.go similarity index 82% rename from internal/prinfo/generator.go rename to pkg/prinfo/generator.go index 713a04117..e418d3cbc 100644 --- a/internal/prinfo/generator.go +++ b/pkg/prinfo/generator.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,8 +32,8 @@ func NewGenerator() *Generator { return &Generator{} } -// GeneratePRInfoSchema generates a JSON schema for the PR/MR info data. -func (g *Generator) GeneratePRInfoSchema(version string) *jsonschema.Schema { +// GenerateSchema generates a JSON schema for the PR/MR info data. +func (g *Generator) GenerateSchema(version Version) *jsonschema.Schema { r := &jsonschema.Reflector{ DoNotReference: true, ExpandedStruct: true, @@ -44,7 +44,7 @@ func (g *Generator) GeneratePRInfoSchema(version string) *jsonschema.Schema { schema := r.Reflect(&Data{}) - schema.ID = jsonschema.ID(fmt.Sprintf("https://schemas.chainloop.dev/prinfo/%s/pr-info.schema.json", version)) + schema.ID = jsonschema.ID(SchemaURL(version)) schema.Title = "Pull Request / Merge Request Information" schema.Description = "Schema for Pull Request or Merge Request metadata collected during attestation" // we want to have a specific version of the schema to avoid compatibility issues @@ -54,7 +54,7 @@ func (g *Generator) GeneratePRInfoSchema(version string) *jsonschema.Schema { } // Save writes the schema to a file -func (g *Generator) Save(schema *jsonschema.Schema, outputDir, version string) error { +func (g *Generator) Save(schema *jsonschema.Schema, outputDir string, version Version) error { schemaJSON, err := json.MarshalIndent(schema, "", " ") if err != nil { return fmt.Errorf("failed to marshal schema to JSON: %w", err) diff --git a/internal/prinfo/prinfo.go b/pkg/prinfo/prinfo.go similarity index 100% rename from internal/prinfo/prinfo.go rename to pkg/prinfo/prinfo.go diff --git a/internal/prinfo/prinfo_test.go b/pkg/prinfo/prinfo_test.go similarity index 96% rename from internal/prinfo/prinfo_test.go rename to pkg/prinfo/prinfo_test.go index 098d121a8..8ad045068 100644 --- a/internal/prinfo/prinfo_test.go +++ b/pkg/prinfo/prinfo_test.go @@ -19,8 +19,6 @@ import ( "encoding/json" "testing" - "github.com/chainloop-dev/chainloop/internal/schemavalidators" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -175,7 +173,7 @@ func TestValidatePRInfo(t *testing.T) { require.NoError(t, err) } - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_1) + err = Validate(data, Version1_1) if tc.wantErr { assert.Error(t, err) } else { @@ -253,7 +251,7 @@ func TestValidatePRInfoV1_2(t *testing.T) { err := json.Unmarshal([]byte(tc.data), &data) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_2) + err = Validate(data, Version1_2) if tc.wantErr { assert.Error(t, err) } else { @@ -299,7 +297,7 @@ func TestValidatePRInfoV1_0BackwardCompat(t *testing.T) { err := json.Unmarshal([]byte(tc.data), &data) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_0) + err = Validate(data, Version1_0) if tc.wantErr { assert.Error(t, err) } else { @@ -429,7 +427,7 @@ func TestValidatePRInfoV1_3(t *testing.T) { err := json.Unmarshal([]byte(tc.data), &data) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_3) + err = Validate(data, Version1_3) if tc.wantErr { assert.Error(t, err) } else { diff --git a/pkg/prinfo/schema.go b/pkg/prinfo/schema.go new file mode 100644 index 000000000..123284849 --- /dev/null +++ b/pkg/prinfo/schema.go @@ -0,0 +1,130 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prinfo + +import ( + _ "embed" + "errors" + "fmt" + "strings" + "sync" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// Version represents the version of the PR/MR info schema. +type Version string + +const ( + // Version1_0 represents PR/MR Info version 1.0 schema. + Version1_0 Version = "1.0" + // Version1_1 represents PR/MR Info version 1.1 schema (adds reviewers). + Version1_1 Version = "1.1" + // Version1_2 represents PR/MR Info version 1.2 schema (adds requested and review_status to reviewers). + Version1_2 Version = "1.2" + // Version1_3 represents PR/MR Info version 1.3 schema (author as object with type). + Version1_3 Version = "1.3" + + // LatestVersion is the schema version emitted by this package. + LatestVersion = Version1_3 +) + +// ErrInvalidJSONPayload represents an error for an invalid JSON payload. +var ErrInvalidJSONPayload = errors.New("invalid JSON payload") + +var ( + //go:embed schemas/pr-info-1.0.schema.json + specVersion1_0 string + //go:embed schemas/pr-info-1.1.schema.json + specVersion1_1 string + //go:embed schemas/pr-info-1.2.schema.json + specVersion1_2 string + //go:embed schemas/pr-info-1.3.schema.json + specVersion1_3 string +) + +// rawSchemas holds the canonical JSON schema documents indexed by version. +var rawSchemas = map[Version]string{ + Version1_0: specVersion1_0, + Version1_1: specVersion1_1, + Version1_2: specVersion1_2, + Version1_3: specVersion1_3, +} + +var ( + compiledSchemas map[Version]*jsonschema.Schema + compileOnce sync.Once +) + +// SchemaURL returns the canonical, published URL of the given schema version. +func SchemaURL(version Version) string { + return fmt.Sprintf("https://schemas.chainloop.dev/prinfo/%s/pr-info.schema.json", version) +} + +// Schema returns the raw JSON schema document for the given version. +func Schema(version Version) (string, error) { + raw, ok := rawSchemas[version] + if !ok { + return "", fmt.Errorf("invalid PR info schema version %q", version) + } + + return raw, nil +} + +// Versions returns the supported schema versions, oldest first. +func Versions() []Version { + return []Version{Version1_0, Version1_1, Version1_2, Version1_3} +} + +func initSchemas() { + compiler := jsonschema.NewCompiler() + for _, version := range Versions() { + url := SchemaURL(version) + if err := compiler.AddResource(url, strings.NewReader(rawSchemas[version])); err != nil { + panic(fmt.Sprintf("prinfo: failed to add resource %s: %v", url, err)) + } + } + + compiledSchemas = make(map[Version]*jsonschema.Schema, len(rawSchemas)) + for _, version := range Versions() { + compiledSchemas[version] = compiler.MustCompile(SchemaURL(version)) + } +} + +// Validate validates a generically-decoded PR/MR info data payload against the +// given schema version. An empty version defaults to LatestVersion. +func Validate(data any, version Version) error { + compileOnce.Do(initSchemas) + + if version == "" { + version = LatestVersion + } + + schema, ok := compiledSchemas[version] + if !ok { + return fmt.Errorf("invalid PR info schema version %q", version) + } + + if err := schema.Validate(data); err != nil { + var invalidJSONTypeError jsonschema.InvalidJSONTypeError + if errors.As(err, &invalidJSONTypeError) { + return ErrInvalidJSONPayload + } + return err + } + + return nil +} diff --git a/pkg/prinfo/schema_test.go b/pkg/prinfo/schema_test.go new file mode 100644 index 000000000..9ca50bc88 --- /dev/null +++ b/pkg/prinfo/schema_test.go @@ -0,0 +1,201 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prinfo_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/prinfo" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The evidence advertises a schema id, and that id must point at a schema this +// package actually ships. Consumers rely on it to pick the right validator. +func TestEvidenceSchemaURLMatchesLatestVersion(t *testing.T) { + assert.Equal(t, prinfo.SchemaURL(prinfo.LatestVersion), prinfo.EvidenceSchemaURL) + + raw, err := prinfo.Schema(prinfo.LatestVersion) + require.NoError(t, err) + + var doc struct { + ID string `json:"$id"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &doc)) + assert.Equal(t, prinfo.EvidenceSchemaURL, doc.ID) +} + +func TestSchema(t *testing.T) { + for _, version := range prinfo.Versions() { + t.Run(string(version), func(t *testing.T) { + raw, err := prinfo.Schema(version) + require.NoError(t, err) + assert.NotEmpty(t, raw) + + var doc struct { + ID string `json:"$id"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &doc)) + assert.Equal(t, prinfo.SchemaURL(version), doc.ID) + }) + } + + t.Run("unknown version", func(t *testing.T) { + _, err := prinfo.Schema("9.9") + assert.ErrorContains(t, err, `invalid PR info schema version "9.9"`) + }) +} + +// Published schemas are immutable: 1.0-1.2 take the author as a plain string and only +// 1.3 introduced the object form (keeping the string one for backwards compatibility). +// A stale //go:generate directive once rewrote the published 1.2 schema from a later +// revision of Data, making it reject the very payloads it had been published to accept. +func TestPublishedAuthorShapeIsStable(t *testing.T) { + const stringAuthor = `"octocat"` + const objectAuthor = `{"login": "octocat", "type": "User"}` + + testCases := []struct { + version prinfo.Version + author string + wantErr bool + authorForm string + }{ + {version: prinfo.Version1_0, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_0, author: objectAuthor, authorForm: "object", wantErr: true}, + {version: prinfo.Version1_1, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_1, author: objectAuthor, authorForm: "object", wantErr: true}, + {version: prinfo.Version1_2, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_2, author: objectAuthor, authorForm: "object", wantErr: true}, + {version: prinfo.Version1_3, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_3, author: objectAuthor, authorForm: "object"}, + } + + for _, tc := range testCases { + t.Run(string(tc.version)+"/"+tc.authorForm, func(t *testing.T) { + payload := `{ + "platform": "github", + "type": "pull_request", + "number": "123", + "url": "https://github.com/owner/repo/pull/123", + "author": ` + tc.author + ` + }` + + var data any + require.NoError(t, json.Unmarshal([]byte(payload), &data)) + + err := prinfo.Validate(data, tc.version) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestNewEvidence(t *testing.T) { + evidence := prinfo.NewEvidence(prinfo.Data{ + Platform: "github", + Type: "pull_request", + Number: "1", + URL: "https://github.com/owner/repo/pull/1", + }) + + assert.Equal(t, prinfo.EvidenceID, evidence.ID) + assert.Equal(t, prinfo.EvidenceSchemaURL, evidence.Schema) + + // the payload it produces must validate against the schema it declares + raw, err := json.Marshal(evidence.Data) + require.NoError(t, err) + + var data any + require.NoError(t, json.Unmarshal(raw, &data)) + require.NoError(t, prinfo.Validate(data, prinfo.LatestVersion)) +} + +func TestValidateFromFile(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + }{ + { + name: "valid PR info with all fields", + filePath: "./testdata/pr_info_valid.json", + }, + { + name: "missing required fields", + filePath: "./testdata/pr_info_missing_required.json", + wantErr: "missing properties", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f, err := os.ReadFile(tc.filePath) + require.NoError(t, err) + + var v any + require.NoError(t, json.Unmarshal(f, &v)) + + // an empty version falls back to the latest one + err = prinfo.Validate(v, "") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + }) + } +} + +func TestValidateErrors(t *testing.T) { + testCases := []struct { + name string + data any + version prinfo.Version + wantErr string + }{ + { + name: "completely wrong format", + data: map[string]any{"spdxVersion": "SPDX-2.3"}, + version: prinfo.LatestVersion, + wantErr: "missing properties", + }, + { + name: "unknown version", + data: map[string]any{}, + version: "9.9", + wantErr: `invalid PR info schema version "9.9"`, + }, + { + name: "payload not decoded from JSON", + data: struct{ Platform string }{Platform: "github"}, + version: prinfo.LatestVersion, + wantErr: prinfo.ErrInvalidJSONPayload.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.ErrorContains(t, prinfo.Validate(tc.data, tc.version), tc.wantErr) + }) + } +} diff --git a/pkg/prinfo/schemas/generate.go b/pkg/prinfo/schemas/generate.go new file mode 100644 index 000000000..948b56800 --- /dev/null +++ b/pkg/prinfo/schemas/generate.go @@ -0,0 +1,62 @@ +// +// Copyright 2025-2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/chainloop-dev/chainloop/pkg/prinfo" +) + +// Scaffolds the JSON schema for a new PR/MR info version out of the prinfo.Data struct: +// +// go run ./pkg/prinfo/schemas/generate.go --output-dir ./pkg/prinfo/schemas --version +func main() { + var outputDir string + var version string + var force bool + + flag.StringVar(&outputDir, "output-dir", ".", "Directory to output the schema files") + flag.StringVar(&version, "version", "", "Schema version to generate, e.g. 1.4 (required)") + flag.BoolVar(&force, "force", false, "Overwrite an already published schema version") + flag.Parse() + + if version == "" { + fmt.Fprintf(os.Stderr, "Error: --version is required, latest published is %s\n", prinfo.LatestVersion) + os.Exit(1) + } + + // Published schemas are immutable: they are what already-crafted attestations are + // validated against, and they may carry constructs the reflector cannot express, + // such as the string/object author union in 1.3. + if _, err := prinfo.Schema(prinfo.Version(version)); err == nil && !force { + fmt.Fprintf(os.Stderr, "Error: schema %s is already published, refusing to overwrite it. Pass --force to override.\n", version) + os.Exit(1) + } + + generator := prinfo.NewGenerator() + + fmt.Printf("Generating JSON schema for PR/MR Info\n") + sch := generator.GenerateSchema(prinfo.Version(version)) + if err := generator.Save(sch, outputDir, prinfo.Version(version)); err != nil { + fmt.Fprintf(os.Stderr, "Error writing schema: %v\n", err) + os.Exit(1) + } + + fmt.Printf("JSON schema successfully generated at %s/pr-info-%s.schema.json\n", outputDir, version) +} diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.0.schema.json b/pkg/prinfo/schemas/pr-info-1.0.schema.json similarity index 100% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.0.schema.json rename to pkg/prinfo/schemas/pr-info-1.0.schema.json diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.1.schema.json b/pkg/prinfo/schemas/pr-info-1.1.schema.json similarity index 100% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.1.schema.json rename to pkg/prinfo/schemas/pr-info-1.1.schema.json diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.2.schema.json b/pkg/prinfo/schemas/pr-info-1.2.schema.json similarity index 83% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.2.schema.json rename to pkg/prinfo/schemas/pr-info-1.2.schema.json index a7238db51..96e31b1ee 100644 --- a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.2.schema.json +++ b/pkg/prinfo/schemas/pr-info-1.2.schema.json @@ -44,28 +44,8 @@ "description": "Direct URL to the PR/MR" }, "author": { - "properties": { - "login": { - "type": "string", - "description": "Username of the PR/MR author" - }, - "type": { - "type": "string", - "enum": [ - "User", - "Bot", - "unknown" - ], - "description": "Account type of the PR/MR author" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "login", - "type" - ], - "description": "The PR/MR author" + "type": "string", + "description": "Username of the PR/MR author" }, "reviewers": { "items": { @@ -121,4 +101,4 @@ ], "title": "Pull Request / Merge Request Information", "description": "Schema for Pull Request or Merge Request metadata collected during attestation" -} \ No newline at end of file +} diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.3.schema.json b/pkg/prinfo/schemas/pr-info-1.3.schema.json similarity index 100% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.3.schema.json rename to pkg/prinfo/schemas/pr-info-1.3.schema.json diff --git a/internal/schemavalidators/testdata/pr_info_missing_required.json b/pkg/prinfo/testdata/pr_info_missing_required.json similarity index 100% rename from internal/schemavalidators/testdata/pr_info_missing_required.json rename to pkg/prinfo/testdata/pr_info_missing_required.json diff --git a/internal/schemavalidators/testdata/pr_info_valid.json b/pkg/prinfo/testdata/pr_info_valid.json similarity index 100% rename from internal/schemavalidators/testdata/pr_info_valid.json rename to pkg/prinfo/testdata/pr_info_valid.json