Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ client := forges.NewClient(
repo, err := client.FetchRepository(ctx, "https://github.com/octocat/hello-world")
```

The `Forge` interface exposes services for repos, issues, pull requests, reviews, releases, CI, branches, labels, milestones, deploy keys, webhooks, secrets, notifications, files, collaborators, commit statuses and commits. Each backend implements these using its native SDK.
The `Forge` interface exposes services for repos, issues, pull requests, reviews, releases, CI, branches, labels, milestones, deploy keys, webhooks, secrets, notifications, files, collaborators, commit statuses, commits and artifact attestations. Each backend implements these using its native SDK.

Under TinyGo, `gitlab.New` retains its signature and returns a backend whose API operations report `forges.ErrNotSupported`. GitLab URL parsing, web link builders and webhook parsing remain available. Native Go retains the full GitLab implementation.

Expand Down Expand Up @@ -217,6 +217,25 @@ http.HandleFunc("/hook", func(w http.ResponseWriter, r *http.Request) {

Each backend also exports `ParseWebhook(r, secret)` (for example `github.ParseWebhook`), which needs no API client.

Artifact attestations, such as SLSA provenance and SBOMs signed as Sigstore bundles, can be looked up by repository and artifact digest on GitHub and GitLab. `List` follows pagination and returns metadata for every attestation; `Download` then fetches each original bundle without verifying it. An empty result means the lookup succeeded and found nothing. Request failures are returned as errors, and other backends return `forges.ErrNotSupported`:

```go
digest := "sha256:5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa"
atts, err := client.ListAttestations(ctx, "https://github.com/cli/cli", digest, forges.ListAttestationOpts{})
if err != nil {
log.Fatal(err)
}
for _, att := range atts {
bundle, err := client.DownloadAttestation(ctx, "https://github.com/cli/cli", att)
if err != nil {
log.Fatal(err)
}
// verify bundle with a Sigstore client
}
```

`ListAttestationOpts.PredicateType` filters by predicate type URI or by the short names `provenance` and `sbom`. GitLab's attestation API is an Ultimate-tier experiment behind the `slsa_provenance_statement` feature flag, and GitLab answers 404 when it is off, so `List` reports `forges.ErrNotFound` for a project without it.

Self-hosted instances can be registered explicitly or detected automatically:

```go
Expand Down
109 changes: 109 additions & 0 deletions attestations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package forges

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)

// sha256DigestLength is the number of hexadecimal characters in a SHA-256
// digest.
const sha256DigestLength = 64

// ErrInvalidDigest is returned when an artifact digest is not of the form
// "sha256:<64 hex digits>".
var ErrInvalidDigest = errors.New("artifact digest must be sha256:<64 hex digits>")

// AttestationService discovers and retrieves artifact attestations, such as
// SLSA provenance or SBOMs signed as Sigstore bundles, that a forge stores for
// an artifact digest. Fetching is kept separate from verification: callers
// verify the returned bundles themselves.
type AttestationService interface {
// List returns every attestation the repository holds for the artifact
// with the given digest, following pagination. digest has the form
// "sha256:<hex>". An empty result with a nil error means the lookup
// succeeded and the forge holds no attestations for the artifact; request
// failures, including authentication and rate limit errors, are returned
// as errors. Implementations return ErrNotFound when the repository does
// not exist and ErrNotSupported when the forge has no attestation API.
List(ctx context.Context, owner, repo, digest string, opts ListAttestationOpts) ([]Attestation, error)
// Download returns the original Sigstore bundle JSON for an attestation
// returned by List. When the listing already included the bundle it is
// returned without a request.
Download(ctx context.Context, owner, repo string, att Attestation) (json.RawMessage, error)
}

// Attestation holds normalized metadata about an artifact attestation.
type Attestation struct {
// ID identifies the attestation within the repository. GitLab sets the
// attestation IID; GitHub has no such ID and leaves it empty.
ID string `json:"id,omitempty"`
SubjectDigest string `json:"subject_digest"` // sha256:<hex>
// PredicateType is the in-toto predicate type URI. It is empty when the
// forge only reports it inside the bundle, as GitHub does.
PredicateType string `json:"predicate_type,omitempty"`
Status string `json:"status,omitempty"` // forge-native, e.g. GitLab's success or error
// BundleURL is where Download fetches the bundle from. GitHub's is a
// signed blob storage link that expires shortly after listing.
BundleURL string `json:"bundle_url,omitempty"`
Bundle json.RawMessage `json:"bundle,omitempty"` // set when the listing includes the bundle
CreatedAt time.Time `json:"created_at,omitzero"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
}

// ListAttestationOpts holds options for listing attestations.
type ListAttestationOpts struct {
// PredicateType keeps only attestations of this predicate type. It
// accepts a full predicate type URI or the short names "provenance" and
// "sbom".
PredicateType string
Limit int // max total results; 0 = unlimited
}

// ParseSHA256Digest validates an artifact digest of the form "sha256:<hex>"
// and returns its hex value in lowercase. Any other form returns
// ErrInvalidDigest.
func ParseSHA256Digest(digest string) (string, error) {
hex, ok := strings.CutPrefix(digest, "sha256:")
if !ok || len(hex) != sha256DigestLength {
return "", fmt.Errorf("%w: %q", ErrInvalidDigest, digest)
}
for _, char := range hex {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
return "", fmt.Errorf("%w: %q", ErrInvalidDigest, digest)
}
}
return strings.ToLower(hex), nil
}

// ListAttestations lists the attestations for the artifact with the given
// digest in the repository at repoURL, routing to the forge registered for
// that URL's domain.
func (c *Client) ListAttestations(ctx context.Context, repoURL, digest string, opts ListAttestationOpts) ([]Attestation, error) {
domain, owner, repo, err := ParseRepoURL(repoURL)
if err != nil {
return nil, err
}
f, err := c.forgeFor(domain)
if err != nil {
return nil, err
}
return f.Attestations().List(ctx, owner, repo, digest, opts)
}

// DownloadAttestation returns the Sigstore bundle for an attestation that
// ListAttestations returned for the repository at repoURL.
func (c *Client) DownloadAttestation(ctx context.Context, repoURL string, att Attestation) (json.RawMessage, error) {
domain, owner, repo, err := ParseRepoURL(repoURL)
if err != nil {
return nil, err
}
f, err := c.forgeFor(domain)
if err != nil {
return nil, err
}
return f.Attestations().Download(ctx, owner, repo, att)
}
96 changes: 96 additions & 0 deletions attestations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package forges

import (
"context"
"errors"
"strings"
"testing"
)

const testDigestHex = "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa"

func TestParseSHA256Digest(t *testing.T) {
tests := []struct {
name string
digest string
want string
wantErr bool
}{
{"lowercase", "sha256:" + testDigestHex, testDigestHex, false},
{"uppercase hex", "sha256:" + strings.ToUpper(testDigestHex), testDigestHex, false},
{"bare hex", testDigestHex, "", true},
{"other algorithm", "sha512:" + testDigestHex, "", true},
{"uppercase algorithm", "SHA256:" + testDigestHex, "", true},
{"too short", "sha256:" + testDigestHex[:63], "", true},
{"too long", "sha256:" + testDigestHex + "a", "", true},
{"non-hex", "sha256:" + testDigestHex[:63] + "g", "", true},
{"path separator", "sha256:" + testDigestHex[:62] + "/a", "", true},
{"empty", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSHA256Digest(tt.digest)
if tt.wantErr {
if !errors.Is(err, ErrInvalidDigest) {
t.Errorf("ParseSHA256Digest(%q) error = %v, want ErrInvalidDigest", tt.digest, err)
}
return
}
if err != nil {
t.Fatalf("ParseSHA256Digest(%q) error = %v", tt.digest, err)
}
if got != tt.want {
t.Errorf("ParseSHA256Digest(%q) = %q, want %q", tt.digest, got, tt.want)
}
})
}
}

func TestClientListAttestationsRoutes(t *testing.T) {
svc := &mockAttestationService{attestations: []Attestation{{ID: "1"}, {ID: "2"}}}
c := &Client{
forges: map[string]Forge{"example.com": &mockForge{attestations: svc}},
tokens: make(map[string]string),
}

opts := ListAttestationOpts{PredicateType: "provenance", Limit: 5}
atts, err := c.ListAttestations(context.Background(), "https://example.com/test/repo", "sha256:"+testDigestHex, opts)
if err != nil {
t.Fatalf("ListAttestations() error = %v", err)
}
if len(atts) != 2 {
t.Fatalf("expected 2 attestations, got %d", len(atts))
}
if svc.lastOwner != "test" || svc.lastRepo != "repo" || svc.lastDigest != "sha256:"+testDigestHex {
t.Errorf("backend received owner=%q repo=%q digest=%q", svc.lastOwner, svc.lastRepo, svc.lastDigest)
}
if svc.lastOpts != opts {
t.Errorf("backend received opts %+v, want %+v", svc.lastOpts, opts)
}
}

func TestClientDownloadAttestationRoutes(t *testing.T) {
svc := &mockAttestationService{bundle: []byte(`{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}`)}
c := &Client{
forges: map[string]Forge{"example.com": &mockForge{attestations: svc}},
tokens: make(map[string]string),
}

bundle, err := c.DownloadAttestation(context.Background(), "https://example.com/test/repo", Attestation{ID: "7"})
if err != nil {
t.Fatalf("DownloadAttestation() error = %v", err)
}
if string(bundle) != string(svc.bundle) {
t.Errorf("DownloadAttestation() = %s, want %s", bundle, svc.bundle)
}
if svc.lastOwner != "test" || svc.lastRepo != "repo" || svc.lastAtt.ID != "7" {
t.Errorf("backend received owner=%q repo=%q id=%q", svc.lastOwner, svc.lastRepo, svc.lastAtt.ID)
}
}

func TestClientListAttestationsUnregisteredDomain(t *testing.T) {
c := NewClient()
if _, err := c.ListAttestations(context.Background(), "https://example.com/test/repo", "sha256:"+testDigestHex, ListAttestationOpts{}); err == nil {
t.Error("ListAttestations() error = nil, want error for unregistered domain")
}
}
23 changes: 23 additions & 0 deletions bitbucket/attestations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package bitbucket

import (
"context"
"encoding/json"

forge "github.com/git-pkgs/forge"
)

// Bitbucket Cloud has no artifact attestation API.
type bitbucketAttestationService struct{}

func (f *bitbucketForge) Attestations() forge.AttestationService {
return &bitbucketAttestationService{}
}

func (s *bitbucketAttestationService) List(_ context.Context, _, _, _ string, _ forge.ListAttestationOpts) ([]forge.Attestation, error) {
return nil, forge.ErrNotSupported
}

func (s *bitbucketAttestationService) Download(_ context.Context, _, _ string, _ forge.Attestation) (json.RawMessage, error) {
return nil, forge.ErrNotSupported
}
24 changes: 24 additions & 0 deletions bitbucket/attestations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package bitbucket

import (
"context"
"errors"
"strings"
"testing"

forge "github.com/git-pkgs/forge"
)

func TestBitbucketAttestationsNotSupported(t *testing.T) {
svc := &bitbucketAttestationService{}

_, err := svc.List(context.Background(), "owner", "repo", "sha256:"+strings.Repeat("a", 64), forge.ListAttestationOpts{})
if !errors.Is(err, forge.ErrNotSupported) {
t.Errorf("List: expected forge.ErrNotSupported, got %v", err)
}

_, err = svc.Download(context.Background(), "owner", "repo", forge.Attestation{ID: "1"})
if !errors.Is(err, forge.ErrNotSupported) {
t.Errorf("Download: expected forge.ErrNotSupported, got %v", err)
}
}
1 change: 1 addition & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ type Forge interface {
CommitStatuses() CommitStatusService
CheckRuns() CheckRunService
Commits() CommitService
Attestations() AttestationService
GetRateLimit(ctx context.Context) (*RateLimit, error)
// GetAuthenticatedUser returns the profile of the user the client's
// token belongs to.
Expand Down
34 changes: 34 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package forges

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -733,6 +734,7 @@ type mockForge struct {
secretService *mockSecretService
reviewService *mockReviewService
commitService *mockCommitService
attestations *mockAttestationService
}

func (m *mockForge) Repos() RepoService {
Expand Down Expand Up @@ -876,6 +878,13 @@ func (m *mockForge) Commits() CommitService {
return &mockCommitService{}
}

func (m *mockForge) Attestations() AttestationService {
if m.attestations != nil {
return m.attestations
}
return &mockAttestationService{}
}

func (m *mockForge) GetRateLimit(_ context.Context) (*RateLimit, error) {
return nil, ErrNotSupported
}
Expand Down Expand Up @@ -959,6 +968,31 @@ func (m *mockCommitService) ResolveCommit(_ context.Context, owner, repo, ref st
return m.sha, m.err
}

type mockAttestationService struct {
attestations []Attestation
bundle json.RawMessage
lastOwner string
lastRepo string
lastDigest string
lastOpts ListAttestationOpts
lastAtt Attestation
}

func (m *mockAttestationService) List(_ context.Context, owner, repo, digest string, opts ListAttestationOpts) ([]Attestation, error) {
m.lastOwner = owner
m.lastRepo = repo
m.lastDigest = digest
m.lastOpts = opts
return m.attestations, nil
}

func (m *mockAttestationService) Download(_ context.Context, owner, repo string, att Attestation) (json.RawMessage, error) {
m.lastOwner = owner
m.lastRepo = repo
m.lastAtt = att
return m.bundle, nil
}

type mockRepoService struct {
repo *Repository
repos []Repository
Expand Down
6 changes: 6 additions & 0 deletions gerrit/gerrit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

forge "github.com/git-pkgs/forge"
Expand Down Expand Up @@ -261,6 +262,11 @@ func TestUnsupportedServicesReturnErrNotSupported(t *testing.T) {
if !errors.Is(err, forge.ErrNotSupported) {
t.Fatalf("Notifications().GetRepoSubscription error = %v, want ErrNotSupported", err)
}

_, err = f.Attestations().List(context.Background(), "org", "repo", "sha256:"+strings.Repeat("a", 64), forge.ListAttestationOpts{})
if !errors.Is(err, forge.ErrNotSupported) {
t.Fatalf("Attestations().List error = %v, want ErrNotSupported", err)
}
}

func TestParsePath(t *testing.T) {
Expand Down
Loading
Loading