diff --git a/cli/slsa-verifier/verify/utils.go b/cli/slsa-verifier/verify/utils.go index 065e924d8..ca709b508 100644 --- a/cli/slsa-verifier/verify/utils.go +++ b/cli/slsa-verifier/verify/utils.go @@ -19,6 +19,8 @@ import ( "hash" "io" "os" + + "github.com/slsa-framework/slsa-verifier/v2/verifiers/utils" ) func computeFileHash(filePath string, h hash.Hash) (string, error) { @@ -33,3 +35,11 @@ func computeFileHash(filePath string, h hash.Hash) (string, error) { } return hex.EncodeToString(h.Sum(nil)), nil } + +// newTagResolver creates a GitHubTagResolver, optionally authenticated via +// the GITHUB_TOKEN environment variable. +func newTagResolver() utils.TagResolver { + return &utils.GitHubTagResolver{ + Token: os.Getenv("GITHUB_TOKEN"), + } +} diff --git a/cli/slsa-verifier/verify/verify_artifact.go b/cli/slsa-verifier/verify/verify_artifact.go index d6fb4ec0f..b1fb27d45 100644 --- a/cli/slsa-verifier/verify/verify_artifact.go +++ b/cli/slsa-verifier/verify/verify_artifact.go @@ -57,7 +57,8 @@ func (c *VerifyArtifactCommand) Exec(ctx context.Context, artifacts []string) (* } builderOpts := &options.BuilderOpts{ - ExpectedID: c.BuilderID, + ExpectedID: c.BuilderID, + TagResolver: newTagResolver(), } provenance, err := os.ReadFile(c.ProvenancePath) diff --git a/cli/slsa-verifier/verify/verify_github_attestation.go b/cli/slsa-verifier/verify/verify_github_attestation.go index 16eda3bed..1e8626566 100644 --- a/cli/slsa-verifier/verify/verify_github_attestation.go +++ b/cli/slsa-verifier/verify/verify_github_attestation.go @@ -47,7 +47,8 @@ func (c *VerifyGithubAttestationCommand) Exec(ctx context.Context, artifact stri } builderOpts := &options.BuilderOpts{ - ExpectedID: c.BuilderID, + ExpectedID: c.BuilderID, + TagResolver: newTagResolver(), } attestation, err := os.ReadFile(c.AttestationPath) diff --git a/cli/slsa-verifier/verify/verify_image.go b/cli/slsa-verifier/verify/verify_image.go index 3dd5650a9..805e4d691 100644 --- a/cli/slsa-verifier/verify/verify_image.go +++ b/cli/slsa-verifier/verify/verify_image.go @@ -61,7 +61,8 @@ func (c *VerifyImageCommand) Exec(ctx context.Context, artifacts []string) (*uti } builderOpts := &options.BuilderOpts{ - ExpectedID: c.BuilderID, + ExpectedID: c.BuilderID, + TagResolver: newTagResolver(), } var provenance []byte diff --git a/cli/slsa-verifier/verify/verify_npm_package.go b/cli/slsa-verifier/verify/verify_npm_package.go index f7f5bf635..2b116a4e6 100644 --- a/cli/slsa-verifier/verify/verify_npm_package.go +++ b/cli/slsa-verifier/verify/verify_npm_package.go @@ -69,7 +69,8 @@ func (c *VerifyNpmPackageCommand) Exec(ctx context.Context, tarballs []string) ( } builderOpts := &options.BuilderOpts{ - ExpectedID: c.BuilderID, + ExpectedID: c.BuilderID, + TagResolver: newTagResolver(), } attestations, err := os.ReadFile(c.AttestationsPath) diff --git a/options/options.go b/options/options.go index aadcb123b..0d4f422ea 100644 --- a/options/options.go +++ b/options/options.go @@ -1,6 +1,10 @@ package options -import "crypto" +import ( + "crypto" + + "github.com/slsa-framework/slsa-verifier/v2/verifiers/utils" +) // ProvenanceOpts are the options for checking provenance information. type ProvenanceOpts struct { @@ -34,10 +38,14 @@ type ProvenanceOpts struct { ExpectedProvenanceRepository *string } -// BuildOpts are the options for checking the builder. +// BuilderOpts are the options for checking the builder. type BuilderOpts struct { - // ExpectedBuilderID is the builderID passed in from the user. + // ExpectedID is the builderID passed in from the user. ExpectedID *string + + // TagResolver is used to resolve a commit SHA to its associated tags. + // If nil, SHA pinning is not supported and only tag refs are accepted. + TagResolver utils.TagResolver } // VSAOpts are the options for checking the VSA. diff --git a/verifiers/internal/gha/builder.go b/verifiers/internal/gha/builder.go index dbe0479be..5eb71c4e5 100644 --- a/verifiers/internal/gha/builder.go +++ b/verifiers/internal/gha/builder.go @@ -1,6 +1,7 @@ package gha import ( + "context" "crypto/x509" "encoding/asn1" "fmt" @@ -63,7 +64,7 @@ func VerifyCertficateSourceRepository(id *WorkflowIdentity, // Builder IDs are verified against an expected builder ID provided in the // builerOpts, or against the set of defaultBuilders provided. The identiy // in the certificate corresponds to a GitHub workflow's path. -func VerifyBuilderIdentity(id *WorkflowIdentity, +func VerifyBuilderIdentity(ctx context.Context, id *WorkflowIdentity, builderOpts *options.BuilderOpts, defaultBuilders map[string]bool, ) (*utils.TrustedBuilderID, bool, error) { @@ -90,7 +91,7 @@ func VerifyBuilderIdentity(id *WorkflowIdentity, } // Verify the ref is a full semantic version tag. - if err := verifyTrustedBuilderRef(id, workflowTag); err != nil { + if err := verifyTrustedBuilderRef(ctx, id, workflowTag, builderOpts.TagResolver); err != nil { return nil, byob, err } @@ -171,19 +172,26 @@ func isTrustedDelegatorBuilder(certBuilder *utils.TrustedBuilderID, trustedBuild // Only allow `@refs/heads/main` for the builder and the e2e tests that need to work at HEAD. // This lets us use the pre-build builder binary generated during release (release happen at main). // For other projects, we only allow semantic versions that map to a release. -func verifyTrustedBuilderRef(id *WorkflowIdentity, ref string) error { - if (id.SourceRepository == trustedBuilderRepository || - id.SourceRepository == e2eTestRepository) && - options.TestingEnabled() { - // Allow verification on the main branch to support e2e tests. - if ref == "refs/heads/main" { - return nil - } +func verifyTrustedBuilderRef(ctx context.Context, id *WorkflowIdentity, ref string, resolver utils.TagResolver) error { + testing := (id.SourceRepository == trustedBuilderRepository || + id.SourceRepository == e2eTestRepository) && options.TestingEnabled() + + if testing && ref == "refs/heads/main" { + return nil + } - return utils.IsValidBuilderTag(ref, true) + if utils.IsSHA(ref) { + if resolver == nil { + return fmt.Errorf("%w: SHA pinning requires a tag resolver; set GITHUB_TOKEN or provide a resolver", serrors.ErrorInvalidRef) + } + parts := strings.SplitN(id.SourceRepository, "/", 2) + if len(parts) != 2 { + return fmt.Errorf("%w: cannot parse repository %q", serrors.ErrorInvalidRef, id.SourceRepository) + } + return utils.IsValidBuilderSHARef(ctx, ref, testing, resolver, parts[0], parts[1]) } - return utils.IsValidBuilderTag(ref, false) + return utils.IsValidBuilderTag(ref, testing) } func getExtension(cert *x509.Certificate, oid asn1.ObjectIdentifier, encoded bool) (string, error) { diff --git a/verifiers/internal/gha/builder_test.go b/verifiers/internal/gha/builder_test.go index 77576677e..5e20b1298 100644 --- a/verifiers/internal/gha/builder_test.go +++ b/verifiers/internal/gha/builder_test.go @@ -1,6 +1,7 @@ package gha import ( + "context" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" @@ -319,7 +320,7 @@ func Test_VerifyBuilderIdentity(t *testing.T) { if tt.builderID != "" { opts.ExpectedID = &tt.builderID } - id, byob, err := VerifyBuilderIdentity(tt.workflow, opts, tt.defaults) + id, byob, err := VerifyBuilderIdentity(context.Background(), tt.workflow, opts, tt.defaults) if byob != tt.byob { t.Errorf("unexpected byob value:\n%s", cmp.Diff(tt.byob, byob)) } @@ -452,6 +453,85 @@ func asStringPointer(s string) *string { return &s } +// fakeTagResolver is a TagResolver for testing. +type fakeTagResolver struct { + tags map[string][]string // sha → tag names +} + +func (f *fakeTagResolver) TagsForCommitSHA(_ context.Context, _, _, sha string) ([]string, error) { + return f.tags[sha], nil +} + +func Test_verifyTrustedBuilderRefSHA(t *testing.T) { + t.Parallel() + + const validSHA = "abc0123456789abcdef0123456789abcdef01234" + + tests := []struct { + name string + workflow *WorkflowIdentity + ref string + resolver utils.TagResolver + err error + }{ + { + name: "SHA pinned to valid semver tag - pass", + workflow: &WorkflowIdentity{ + SourceRepository: "slsa-framework/slsa-github-generator", + Issuer: certOidcIssuer, + }, + ref: validSHA, + resolver: &fakeTagResolver{ + tags: map[string][]string{validSHA: {"v1.2.3"}}, + }, + }, + { + name: "SHA pinned to no tags - fail", + workflow: &WorkflowIdentity{ + SourceRepository: "slsa-framework/slsa-github-generator", + Issuer: certOidcIssuer, + }, + ref: validSHA, + resolver: &fakeTagResolver{ + tags: map[string][]string{}, + }, + err: serrors.ErrorInvalidRef, + }, + { + name: "SHA pinned to non-semver tag only - fail", + workflow: &WorkflowIdentity{ + SourceRepository: "slsa-framework/slsa-github-generator", + Issuer: certOidcIssuer, + }, + ref: validSHA, + resolver: &fakeTagResolver{ + tags: map[string][]string{validSHA: {"main-build-123"}}, + }, + err: serrors.ErrorInvalidRef, + }, + { + name: "SHA with nil resolver - fail", + workflow: &WorkflowIdentity{ + SourceRepository: "slsa-framework/slsa-github-generator", + Issuer: certOidcIssuer, + }, + ref: validSHA, + resolver: nil, + err: serrors.ErrorInvalidRef, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := verifyTrustedBuilderRef(context.Background(), tt.workflow, tt.ref, tt.resolver) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error (-want +got):\n%s", diff) + } + }) + } +} + func Test_verifyTrustedBuilderID(t *testing.T) { t.Parallel() tests := []struct { @@ -815,7 +895,7 @@ func Test_verifyTrustedBuilderRef(t *testing.T) { t.Setenv("SLSA_VERIFIER_TESTING", "") } - err := verifyTrustedBuilderRef(&wf, tt.builderRef) + err := verifyTrustedBuilderRef(context.Background(), &wf, tt.builderRef, nil) if !errCmp(err, tt.expected) { t.Error(cmp.Diff(err, tt.expected, cmpopts.EquateErrors())) } diff --git a/verifiers/internal/gha/npm.go b/verifiers/internal/gha/npm.go index 67d25fbf8..09a77c7ec 100644 --- a/verifiers/internal/gha/npm.go +++ b/verifiers/internal/gha/npm.go @@ -294,13 +294,13 @@ func (n *Npm) verifyPackageVersion(version *string) error { return nil } -func (n *Npm) verifyBuilderID( +func (n *Npm) verifyBuilderID(ctx context.Context, provenanceOpts *options.ProvenanceOpts, builderOpts *options.BuilderOpts, defaultBuilders map[string]bool, ) (*utils.TrustedBuilderID, error) { // Verify certificate information. - builder, err := verifyNpmEnvAndCert( + builder, err := verifyNpmEnvAndCert(ctx, n.ProvenanceEnvelope(), n.ProvenanceLeafCertificate(), provenanceOpts, builderOpts, diff --git a/verifiers/internal/gha/verifier.go b/verifiers/internal/gha/verifier.go index f08de6f30..0e69b0231 100644 --- a/verifiers/internal/gha/verifier.go +++ b/verifiers/internal/gha/verifier.go @@ -42,7 +42,7 @@ func (v *GHAVerifier) IsAuthoritativeFor(builderID string) bool { return strings.HasPrefix(builderID, httpsGithubCom) } -func verifyEnvAndCert(env *dsse.Envelope, +func verifyEnvAndCert(ctx context.Context, env *dsse.Envelope, cert *x509.Certificate, provenanceOpts *options.ProvenanceOpts, builderOpts *options.BuilderOpts, @@ -56,7 +56,7 @@ func verifyEnvAndCert(env *dsse.Envelope, } // Verify the builder identity. - verifiedBuilderID, byob, err := VerifyBuilderIdentity(workflowInfo, builderOpts, defaultBuilders) + verifiedBuilderID, byob, err := VerifyBuilderIdentity(ctx, workflowInfo, builderOpts, defaultBuilders) if err != nil { return nil, nil, err } @@ -97,7 +97,7 @@ func verifyEnvAndCert(env *dsse.Envelope, return r, verifiedBuilderID, nil } -func verifyNpmEnvAndCert(env *dsse.Envelope, +func verifyNpmEnvAndCert(ctx context.Context, env *dsse.Envelope, cert *x509.Certificate, provenanceOpts *options.ProvenanceOpts, builderOpts *options.BuilderOpts, @@ -118,7 +118,7 @@ func verifyNpmEnvAndCert(env *dsse.Envelope, delegatorBuilderOpts := options.BuilderOpts{ ExpectedID: &expectedDelegatorWorkflow, } - trustedBuilderID, byob, err := VerifyBuilderIdentity(workflowInfo, &delegatorBuilderOpts, defaultBuilders) + trustedBuilderID, byob, err := VerifyBuilderIdentity(ctx, workflowInfo, &delegatorBuilderOpts, defaultBuilders) // We accept a non-trusted builder for the default npm builder // that uses npm CLI. if err != nil && !errors.Is(err, serrors.ErrorUntrustedReusableWorkflow) { @@ -237,7 +237,7 @@ func (v *GHAVerifier) VerifyArtifact(ctx context.Context, return nil, nil, err } - return verifyEnvAndCert(signedAtt.Envelope, signedAtt.SigningCert, + return verifyEnvAndCert(ctx, signedAtt.Envelope, signedAtt.SigningCert, provenanceOpts, builderOpts, utils.MergeMaps(defaultArtifactTrustedReusableWorkflows, defaultBYOBReusableWorkflows)) } @@ -263,7 +263,7 @@ func (v *GHAVerifier) VerifyGithubAttestation(ctx context.Context, return nil, nil, err } - return verifyEnvAndCert(signedAtt.Envelope, signedAtt.SigningCert, + return verifyEnvAndCert(ctx, signedAtt.Envelope, signedAtt.SigningCert, provenanceOpts, builderOpts, map[string]bool{}) } @@ -323,7 +323,7 @@ func (v *GHAVerifier) VerifyImage(ctx context.Context, fmt.Fprintf(os.Stderr, "unexpected error getting certificate from OCI registry %s", err) continue } - verifiedProvenance, builderID, err = verifyEnvAndCert(env, + verifiedProvenance, builderID, err = verifyEnvAndCert(ctx, env, cert, provenanceOpts, builderOpts, defaultContainerTrustedReusableWorkflows) if err == nil { @@ -365,7 +365,7 @@ func (v *GHAVerifier) VerifyNpmPackage(ctx context.Context, } // Verify provenance builder information. - builder, err := npm.verifyBuilderID( + builder, err := npm.verifyBuilderID(ctx, provenanceOpts, builderOpts, defaultBYOBReusableWorkflows) if err != nil { diff --git a/verifiers/utils/builder.go b/verifiers/utils/builder.go index 9978d0523..462ea280f 100644 --- a/verifiers/utils/builder.go +++ b/verifiers/utils/builder.go @@ -1,6 +1,7 @@ package utils import ( + "context" "fmt" "strings" @@ -151,6 +152,31 @@ func IsValidBuilderTag(ref string, testing bool) error { return nil } +// IsValidBuilderSHARef validates a commit SHA by resolving it to a semver tag +// via the GitHub API. owner and repo identify the builder repository. +func IsValidBuilderSHARef(ctx context.Context, sha string, testing bool, + resolver TagResolver, owner, repo string, +) error { + if !IsSHA(sha) { + return fmt.Errorf("%w: %q is not a valid commit SHA", serrors.ErrorInvalidRef, sha) + } + tags, err := resolver.TagsForCommitSHA(ctx, owner, repo, sha) + if err != nil { + return fmt.Errorf("%w: resolving SHA %s: %w", serrors.ErrorInvalidRef, sha, err) + } + if len(tags) == 0 { + return fmt.Errorf("%w: SHA %s has no associated tags in %s/%s", + serrors.ErrorInvalidRef, sha, owner, repo) + } + for _, tag := range tags { + if err := IsValidBuilderTag("refs/tags/"+tag, testing); err == nil { + return nil + } + } + return fmt.Errorf("%w: SHA %s has no valid semver tag in %s/%s", + serrors.ErrorInvalidRef, sha, owner, repo) +} + func IsValidJreleaserBuilderTag(ref string) error { // Extract the pin. pin, err := TagFromGitRef(ref) diff --git a/verifiers/utils/builder_test.go b/verifiers/utils/builder_test.go index 53ed532e6..a3839c9fe 100644 --- a/verifiers/utils/builder_test.go +++ b/verifiers/utils/builder_test.go @@ -1,6 +1,7 @@ package utils import ( + "context" "fmt" "testing" @@ -817,3 +818,101 @@ func Test_IsValidJreleaserBuilderTag(t *testing.T) { }) } } + +// fakeTagResolver is a TagResolver for testing. +type fakeTagResolver struct { + tags map[string][]string // sha → tag names + err error +} + +func (f *fakeTagResolver) TagsForCommitSHA(_ context.Context, _, _, sha string) ([]string, error) { + if f.err != nil { + return nil, f.err + } + return f.tags[sha], nil +} + +func Test_IsValidBuilderSHARef(t *testing.T) { + t.Parallel() + + const validSHA = "abc0123456789abcdef0123456789abcdef01234" + + tests := []struct { + name string + sha string + testing bool + resolver TagResolver + err error + }{ + { + name: "valid SHA resolving to semver tag", + sha: validSHA, + testing: false, + resolver: &fakeTagResolver{ + tags: map[string][]string{validSHA: {"v1.2.3"}}, + }, + }, + { + name: "valid SHA resolving to semver tag - testing mode", + sha: validSHA, + testing: true, + resolver: &fakeTagResolver{ + tags: map[string][]string{validSHA: {"v1.2.3-rc1"}}, + }, + }, + { + name: "SHA resolving to non-semver tag only", + sha: validSHA, + testing: false, + resolver: &fakeTagResolver{ + tags: map[string][]string{validSHA: {"main-build-123"}}, + }, + err: serrors.ErrorInvalidRef, + }, + { + name: "SHA resolving to no tags", + sha: validSHA, + testing: false, + resolver: &fakeTagResolver{ + tags: map[string][]string{}, + }, + err: serrors.ErrorInvalidRef, + }, + { + name: "not a SHA - tag ref format", + sha: "refs/tags/v1.2.3", + testing: false, + resolver: &fakeTagResolver{ + tags: map[string][]string{}, + }, + err: serrors.ErrorInvalidRef, + }, + { + name: "resolver error", + sha: validSHA, + testing: false, + resolver: &fakeTagResolver{ + err: fmt.Errorf("API error"), + }, + err: serrors.ErrorInvalidRef, + }, + { + name: "SHA with one valid and one invalid tag - passes", + sha: validSHA, + testing: false, + resolver: &fakeTagResolver{ + tags: map[string][]string{validSHA: {"v1.2.3", "latest"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := IsValidBuilderSHARef(context.Background(), tt.sha, tt.testing, tt.resolver, "owner", "repo") + if !cmp.Equal(err, tt.err, cmpopts.EquateErrors()) { + t.Errorf("unexpected error: got %v, want %v", err, tt.err) + } + }) + } +} diff --git a/verifiers/utils/git.go b/verifiers/utils/git.go index 12f586e8e..e053fba51 100644 --- a/verifiers/utils/git.go +++ b/verifiers/utils/git.go @@ -66,3 +66,16 @@ func TagFromGitRef(ref string) (string, error) { func BranchFromGitRef(ref string) (string, error) { return ValidateGitRef("heads", ref) } + +// IsSHA returns true if s is a 40-character lowercase hex Git commit SHA. +func IsSHA(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} diff --git a/verifiers/utils/git_test.go b/verifiers/utils/git_test.go index d8d3a6704..4edbefbbd 100644 --- a/verifiers/utils/git_test.go +++ b/verifiers/utils/git_test.go @@ -8,6 +8,68 @@ import ( serrors "github.com/slsa-framework/slsa-verifier/v2/errors" ) +func Test_IsSHA(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + s string + expected bool + }{ + { + name: "valid 40-char lowercase hex", + s: "abc0123456789abcdef0123456789abcdef01234", + expected: true, + }, + { + name: "all zeros", + s: "0000000000000000000000000000000000000000", + expected: true, + }, + { + name: "39 chars", + s: "abc0123456789abcdef0123456789abcdef0123", + expected: false, + }, + { + name: "41 chars", + s: "abc0123456789abcdef0123456789abcdef012345", + expected: false, + }, + { + name: "uppercase hex", + s: "ABC0123456789ABCDEF0123456789ABCDEF01234", + expected: false, + }, + { + name: "non-hex chars", + s: "xyz0123456789abcdef0123456789abcdef01234", + expected: false, + }, + { + name: "tag ref", + s: "refs/tags/v1.2.3", + expected: false, + }, + { + name: "empty string", + s: "", + expected: false, + }, + } + + for i := range testCases { + tt := testCases[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got, want := IsSHA(tt.s), tt.expected; got != want { + t.Errorf("IsSHA(%q) = %v, want %v", tt.s, got, want) + } + }) + } +} + func Test_NormalizeGitURI(t *testing.T) { t.Parallel() diff --git a/verifiers/utils/github.go b/verifiers/utils/github.go new file mode 100644 index 000000000..c53ab1b6e --- /dev/null +++ b/verifiers/utils/github.go @@ -0,0 +1,64 @@ +package utils + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// TagResolver resolves a commit SHA to the tags that point to it. +type TagResolver interface { + TagsForCommitSHA(ctx context.Context, owner, repo, sha string) ([]string, error) +} + +// GitHubTagResolver resolves tags by querying the GitHub REST API. +type GitHubTagResolver struct { + HTTPClient *http.Client + Token string // optional, for authenticated requests +} + +type ghTag struct { + Name string `json:"name"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` +} + +func (r *GitHubTagResolver) TagsForCommitSHA(ctx context.Context, owner, repo, sha string) ([]string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags?per_page=100", owner, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building GitHub tags request for %s/%s: %w", owner, repo, err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if r.Token != "" { + req.Header.Set("Authorization", "Bearer "+r.Token) + } + + client := r.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching GitHub tags for %s/%s: %w", owner, repo, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %d for %s/%s", resp.StatusCode, owner, repo) + } + + var tags []ghTag + if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil { + return nil, fmt.Errorf("decoding GitHub tags response for %s/%s: %w", owner, repo, err) + } + + var names []string + for _, t := range tags { + if t.Commit.SHA == sha { + names = append(names, t.Name) + } + } + return names, nil +} diff --git a/verifiers/utils/github_test.go b/verifiers/utils/github_test.go new file mode 100644 index 000000000..d1c3dd878 --- /dev/null +++ b/verifiers/utils/github_test.go @@ -0,0 +1,117 @@ +package utils + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestGitHubTagResolver_TagsForCommitSHA(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + sha string + serverTags []ghTag + statusCode int + expectedTags []string + wantErr bool + }{ + { + name: "SHA matches one tag", + sha: "abc0123456789abcdef0123456789abcdef01234", + serverTags: []ghTag{ + {Name: "v1.2.3", Commit: struct{ SHA string `json:"sha"` }{SHA: "abc0123456789abcdef0123456789abcdef01234"}}, + {Name: "v1.2.2", Commit: struct{ SHA string `json:"sha"` }{SHA: "0000000000000000000000000000000000000000"}}, + }, + expectedTags: []string{"v1.2.3"}, + }, + { + name: "SHA matches multiple tags", + sha: "abc0123456789abcdef0123456789abcdef01234", + serverTags: []ghTag{ + {Name: "v1.2.3", Commit: struct{ SHA string `json:"sha"` }{SHA: "abc0123456789abcdef0123456789abcdef01234"}}, + {Name: "v1.2.3-rc1", Commit: struct{ SHA string `json:"sha"` }{SHA: "abc0123456789abcdef0123456789abcdef01234"}}, + }, + expectedTags: []string{"v1.2.3", "v1.2.3-rc1"}, + }, + { + name: "SHA matches no tags", + sha: "abc0123456789abcdef0123456789abcdef01234", + serverTags: []ghTag{ + {Name: "v1.2.3", Commit: struct{ SHA string `json:"sha"` }{SHA: "0000000000000000000000000000000000000000"}}, + }, + expectedTags: nil, + }, + { + name: "non-200 response returns error", + sha: "abc0123456789abcdef0123456789abcdef01234", + statusCode: http.StatusForbidden, + wantErr: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + statusCode := http.StatusOK + if tt.statusCode != 0 { + statusCode = tt.statusCode + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + if statusCode == http.StatusOK { + _ = json.NewEncoder(w).Encode(tt.serverTags) + } + })) + t.Cleanup(server.Close) + + // Use a custom HTTPClient with a transport that redirects to our test server, + // since GitHubTagResolver uses the real GitHub URL. + resolver := &GitHubTagResolver{ + HTTPClient: &http.Client{ + Transport: &hostOverrideTransport{ + base: server.Client().Transport, + baseURL: server.URL, + }, + }, + } + + tags, err := resolver.TagsForCommitSHA(context.Background(), "owner", "repo", tt.sha) + if (err != nil) != tt.wantErr { + t.Fatalf("TagsForCommitSHA() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr { + if diff := cmp.Diff(tt.expectedTags, tags, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("unexpected tags (-want +got):\n%s", diff) + } + } + }) + } +} + +// hostOverrideTransport redirects all requests to baseURL, preserving the path. +type hostOverrideTransport struct { + base http.RoundTripper + baseURL string +} + +func (t *hostOverrideTransport) RoundTrip(req *http.Request) (*http.Response, error) { + override, err := http.NewRequest(req.Method, t.baseURL+req.URL.Path+"?"+req.URL.RawQuery, req.Body) + if err != nil { + return nil, err + } + override.Header = req.Header + rt := t.base + if rt == nil { + rt = http.DefaultTransport + } + return rt.RoundTrip(override) +}