diff --git a/README.md b/README.md index b9d479d..c5ef8fd 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/attestations.go b/attestations.go new file mode 100644 index 0000000..ba32b85 --- /dev/null +++ b/attestations.go @@ -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:". 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: + // 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:" +// 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) +} diff --git a/attestations_test.go b/attestations_test.go new file mode 100644 index 0000000..e542211 --- /dev/null +++ b/attestations_test.go @@ -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") + } +} diff --git a/bitbucket/attestations.go b/bitbucket/attestations.go new file mode 100644 index 0000000..3289818 --- /dev/null +++ b/bitbucket/attestations.go @@ -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 +} diff --git a/bitbucket/attestations_test.go b/bitbucket/attestations_test.go new file mode 100644 index 0000000..9cbfd5e --- /dev/null +++ b/bitbucket/attestations_test.go @@ -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) + } +} diff --git a/forge.go b/forge.go index a600b28..9722592 100644 --- a/forge.go +++ b/forge.go @@ -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. diff --git a/forges_test.go b/forges_test.go index 6b77e7a..bfc7c42 100644 --- a/forges_test.go +++ b/forges_test.go @@ -2,6 +2,7 @@ package forges import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -733,6 +734,7 @@ type mockForge struct { secretService *mockSecretService reviewService *mockReviewService commitService *mockCommitService + attestations *mockAttestationService } func (m *mockForge) Repos() RepoService { @@ -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 } @@ -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 diff --git a/gerrit/gerrit_test.go b/gerrit/gerrit_test.go index 8c7486e..302004a 100644 --- a/gerrit/gerrit_test.go +++ b/gerrit/gerrit_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" forge "github.com/git-pkgs/forge" @@ -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) { diff --git a/gerrit/stubs.go b/gerrit/stubs.go index 1b6d026..d1f8daf 100644 --- a/gerrit/stubs.go +++ b/gerrit/stubs.go @@ -2,6 +2,7 @@ package gerrit import ( "context" + "encoding/json" "io" "net/http" "os" @@ -22,6 +23,7 @@ type unsupportedCollaboratorService struct{} type unsupportedCommitStatusService struct{} type unsupportedCheckRunService struct{} type unsupportedCommitService struct{} +type unsupportedAttestationService struct{} func (f *gerritForge) Issues() forge.IssueService { return &unsupportedIssueService{} } func (f *gerritForge) Labels() forge.LabelService { return &unsupportedLabelService{} } @@ -44,6 +46,9 @@ func (f *gerritForge) CheckRuns() forge.CheckRunService { return &unsupportedCheckRunService{} } func (f *gerritForge) Commits() forge.CommitService { return &unsupportedCommitService{} } +func (f *gerritForge) Attestations() forge.AttestationService { + return &unsupportedAttestationService{} +} func (f *gerritForge) GetRateLimit(context.Context) (*forge.RateLimit, error) { return nil, forge.ErrNotSupported } @@ -257,3 +262,11 @@ func (s *unsupportedCheckRunService) List(context.Context, string, string, strin func (s *unsupportedCommitService) ResolveCommit(context.Context, string, string, string) (string, error) { return "", forge.ErrNotSupported } + +func (s *unsupportedAttestationService) List(context.Context, string, string, string, forge.ListAttestationOpts) ([]forge.Attestation, error) { + return nil, forge.ErrNotSupported +} + +func (s *unsupportedAttestationService) Download(context.Context, string, string, forge.Attestation) (json.RawMessage, error) { + return nil, forge.ErrNotSupported +} diff --git a/gitea/attestations.go b/gitea/attestations.go new file mode 100644 index 0000000..476385f --- /dev/null +++ b/gitea/attestations.go @@ -0,0 +1,23 @@ +package gitea + +import ( + "context" + "encoding/json" + + forge "github.com/git-pkgs/forge" +) + +// Gitea and Forgejo have no artifact attestation API. +type giteaAttestationService struct{} + +func (f *giteaForge) Attestations() forge.AttestationService { + return &giteaAttestationService{} +} + +func (s *giteaAttestationService) List(_ context.Context, _, _, _ string, _ forge.ListAttestationOpts) ([]forge.Attestation, error) { + return nil, forge.ErrNotSupported +} + +func (s *giteaAttestationService) Download(_ context.Context, _, _ string, _ forge.Attestation) (json.RawMessage, error) { + return nil, forge.ErrNotSupported +} diff --git a/gitea/attestations_test.go b/gitea/attestations_test.go new file mode 100644 index 0000000..c5dd32d --- /dev/null +++ b/gitea/attestations_test.go @@ -0,0 +1,24 @@ +package gitea + +import ( + "context" + "errors" + "strings" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestGiteaAttestationsNotSupported(t *testing.T) { + svc := &giteaAttestationService{} + + _, err := svc.List(context.Background(), "testorg", "testrepo", "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(), "testorg", "testrepo", forge.Attestation{ID: "1"}) + if !errors.Is(err, forge.ErrNotSupported) { + t.Errorf("Download: expected forge.ErrNotSupported, got %v", err) + } +} diff --git a/github/attestations.go b/github/attestations.go new file mode 100644 index 0000000..50f0e2f --- /dev/null +++ b/github/attestations.go @@ -0,0 +1,156 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + forge "github.com/git-pkgs/forge" + "github.com/golang/snappy" + "github.com/google/go-github/v82/github" +) + +// maxBundleBytes caps an attestation bundle downloaded from blob storage, +// both as served and once decompressed. +var maxBundleBytes = 32 << 20 + +type gitHubAttestationService struct { + client *github.Client + httpClient *http.Client +} + +func (f *gitHubForge) Attestations() forge.AttestationService { + return &gitHubAttestationService{client: f.client, httpClient: f.httpClient} +} + +// gitHubAttestation is one entry of the list attestations response. bundle is +// null when GitHub links the bundle with bundle_url, which go-github's +// Attestation type lacks. +type gitHubAttestation struct { + Bundle json.RawMessage `json:"bundle"` + BundleURL string `json:"bundle_url"` +} + +// List follows GitHub's cursor pagination, which go-github's +// ListAttestations cannot do because it only takes page numbers. +func (s *gitHubAttestationService) List(ctx context.Context, owner, repo, digest string, opts forge.ListAttestationOpts) ([]forge.Attestation, error) { + hex, err := forge.ParseSHA256Digest(digest) + if err != nil { + return nil, err + } + subject := "sha256:" + hex + + perPage := defaultPageSize + if opts.Limit > 0 && opts.Limit < perPage { + perPage = opts.Limit + } + query := url.Values{} + query.Set("per_page", strconv.Itoa(perPage)) + if opts.PredicateType != "" { + query.Set("predicate_type", opts.PredicateType) + } + path := fmt.Sprintf("repos/%s/%s/attestations/%s", url.PathEscape(owner), url.PathEscape(repo), subject) + + var all []forge.Attestation + for { + req, err := s.client.NewRequest(http.MethodGet, path+"?"+query.Encode(), nil) + if err != nil { + return nil, err + } + var page struct { + Attestations []gitHubAttestation `json:"attestations"` + } + resp, err := s.client.Do(ctx, req, &page) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, wrapErr(err) + } + for _, a := range page.Attestations { + att := forge.Attestation{SubjectDigest: subject, BundleURL: a.BundleURL} + if len(a.Bundle) > 0 && !bytes.Equal(a.Bundle, []byte("null")) { + att.Bundle = a.Bundle + } + all = append(all, att) + } + if resp.After == "" || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + query.Set("after", resp.After) + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +// Download fetches the snappy-compressed bundle that bundle_url points to. +// The URL is a signed blob storage link, so the request goes out without the +// API token and the URL is kept out of returned errors. +func (s *gitHubAttestationService) Download(ctx context.Context, _, _ string, att forge.Attestation) (json.RawMessage, error) { + if len(att.Bundle) > 0 { + return att.Bundle, nil + } + if att.BundleURL == "" { + return nil, errors.New("github: attestation has no bundle or bundle URL") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, att.BundleURL, nil) + if err != nil { + return nil, errors.New("github: invalid attestation bundle URL") + } + // The signed query string is the only credential, so it must not travel + // in cleartext unless the forge's own API is plain http too. + if req.URL.Scheme != "https" && s.client.BaseURL.Scheme != "http" { + return nil, errors.New("github: attestation bundle URL must use https") + } + hc := s.httpClient + if hc == nil { + hc = http.DefaultClient + } + resp, err := hc.Do(req) + if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return nil, fmt.Errorf("github: downloading attestation bundle: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, forge.ClassifyStatus(resp.StatusCode, fmt.Errorf("github: downloading attestation bundle: HTTP %d", resp.StatusCode)) + } + compressed, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBundleBytes)+1)) + if err != nil { + return nil, fmt.Errorf("github: downloading attestation bundle: %w", err) + } + if len(compressed) > maxBundleBytes { + return nil, fmt.Errorf("github: attestation bundle exceeds %d bytes", maxBundleBytes) + } + + size, err := snappy.DecodedLen(compressed) + if err != nil { + return nil, fmt.Errorf("github: decoding attestation bundle: %w", err) + } + if size > maxBundleBytes { + return nil, fmt.Errorf("github: attestation bundle exceeds %d bytes", maxBundleBytes) + } + bundle, err := snappy.Decode(nil, compressed) + if err != nil { + return nil, fmt.Errorf("github: decoding attestation bundle: %w", err) + } + if !json.Valid(bundle) { + return nil, errors.New("github: attestation bundle is not valid JSON") + } + return bundle, nil +} diff --git a/github/attestations_test.go b/github/attestations_test.go new file mode 100644 index 0000000..e09f375 --- /dev/null +++ b/github/attestations_test.go @@ -0,0 +1,339 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/golang/snappy" + "github.com/google/go-github/v82/github" +) + +const ( + testAttestationDigest = "sha256:5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" + testAttestationPath = "/api/v3/repos/octocat/hello/attestations/" + testAttestationDigest + testBundle = `{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","dsseEnvelope":{"payloadType":"application/vnd.in-toto+json"}}` +) + +func newTestGitHubAttestationService(srv *httptest.Server) *gitHubAttestationService { + c := github.NewClient(nil) + c, _ = c.WithEnterpriseURLs(srv.URL+"/api/v3", srv.URL+"/api/v3") + return &gitHubAttestationService{client: c} +} + +func TestGitHubListAttestationsPaginates(t *testing.T) { + var srvURL string + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + assertEqual(t, "per_page", "100", q.Get("per_page")) + w.Header().Set("Content-Type", "application/json") + switch q.Get("after") { + case "": + w.Header().Set("Link", fmt.Sprintf(`<%s%s?per_page=100&after=cursor1>; rel="next"`, srvURL, testAttestationPath)) + _, _ = fmt.Fprintf(w, `{"attestations": [ + {"repository_id": 1, "bundle": %s, "bundle_url": "https://blob.example.com/1.json.sn"}, + {"repository_id": 1, "bundle": null, "bundle_url": "https://blob.example.com/2.json.sn"} + ]}`, testBundle) + case "cursor1": + _, _ = fmt.Fprint(w, `{"attestations": [{"repository_id": 1, "bundle_url": "https://blob.example.com/3.json.sn"}]}`) + default: + t.Errorf("unexpected after cursor %q", q.Get("after")) + } + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + srvURL = srv.URL + + svc := newTestGitHubAttestationService(srv) + atts, err := svc.List(context.Background(), "octocat", "hello", testAttestationDigest[:7]+strings.ToUpper(testAttestationDigest[7:]), forge.ListAttestationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(atts) != 3 { + t.Fatalf("expected 3 attestations, got %d", len(atts)) + } + for i, att := range atts { + assertEqual(t, fmt.Sprintf("SubjectDigest[%d]", i), testAttestationDigest, att.SubjectDigest) + assertEqual(t, fmt.Sprintf("BundleURL[%d]", i), fmt.Sprintf("https://blob.example.com/%d.json.sn", i+1), att.BundleURL) + assertEqual(t, fmt.Sprintf("ID[%d]", i), "", att.ID) + } + assertEqual(t, "Bundle[0]", testBundle, string(atts[0].Bundle)) + if atts[1].Bundle != nil || atts[2].Bundle != nil { + t.Errorf("expected no inline bundle for null or missing bundles, got %q and %q", atts[1].Bundle, atts[2].Bundle) + } +} + +func TestGitHubListAttestationsPredicateTypeAndLimit(t *testing.T) { + var srvURL string + var requests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + assertEqual(t, "predicate_type", "https://slsa.dev/provenance/v1", r.URL.Query().Get("predicate_type")) + assertEqual(t, "per_page", "2", r.URL.Query().Get("per_page")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", fmt.Sprintf(`<%s%s?per_page=2&after=cursor1>; rel="next"`, srvURL, testAttestationPath)) + _, _ = fmt.Fprint(w, `{"attestations": [{"bundle_url": "https://blob.example.com/1.json.sn"}, {"bundle_url": "https://blob.example.com/2.json.sn"}]}`) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + srvURL = srv.URL + + svc := newTestGitHubAttestationService(srv) + atts, err := svc.List(context.Background(), "octocat", "hello", testAttestationDigest, forge.ListAttestationOpts{ + PredicateType: "https://slsa.dev/provenance/v1", + Limit: 2, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(atts) != 2 { + t.Fatalf("expected 2 attestations, got %d", len(atts)) + } + if n := requests.Load(); n != 1 { + t.Errorf("expected 1 request once the limit was reached, got %d", n) + } +} + +func TestGitHubListAttestationsEmpty(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"attestations": []}`) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newTestGitHubAttestationService(srv) + atts, err := svc.List(context.Background(), "octocat", "hello", testAttestationDigest, forge.ListAttestationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(atts) != 0 { + t.Errorf("expected no attestations, got %d", len(atts)) + } +} + +func TestGitHubListAttestationsErrors(t *testing.T) { + tests := []struct { + name string + status int + headers map[string]string + want error + }{ + {name: "not found", status: http.StatusNotFound, want: forge.ErrNotFound}, + {name: "unauthorized", status: http.StatusUnauthorized, want: forge.ErrUnauthorized}, + { + name: "rate limited", + status: http.StatusForbidden, + headers: map[string]string{"X-RateLimit-Limit": "60", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1700000000"}, + want: forge.ErrRateLimited, + }, + {name: "server error", status: http.StatusBadGateway, want: forge.ErrServerError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + for k, v := range tt.headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = fmt.Fprint(w, `{"message": "error"}`) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newTestGitHubAttestationService(srv) + atts, err := svc.List(context.Background(), "octocat", "hello", testAttestationDigest, forge.ListAttestationOpts{}) + if err == nil { + t.Fatalf("expected an error, got %d attestations", len(atts)) + } + if !errors.Is(err, tt.want) { + t.Errorf("expected %v, got %v", tt.want, err) + } + }) + } +} + +func TestGitHubListAttestationsInvalidDigest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL.Path) + })) + defer srv.Close() + + svc := newTestGitHubAttestationService(srv) + _, err := svc.List(context.Background(), "octocat", "hello", "sha256:../../user", forge.ListAttestationOpts{}) + if !errors.Is(err, forge.ErrInvalidDigest) { + t.Errorf("expected ErrInvalidDigest, got %v", err) + } +} + +func TestGitHubDownloadAttestationFromBundleURL(t *testing.T) { + blob := http.NewServeMux() + blob.HandleFunc("GET /attestations/1.json.sn", func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "" { + t.Errorf("bundle request sent Authorization %q", auth) + } + w.Header().Set("Content-Type", "application/x-snappy") + _, _ = w.Write(snappy.Encode(nil, []byte(testBundle))) + }) + blobSrv := httptest.NewTLSServer(blob) + defer blobSrv.Close() + + f := NewWithBase("https://ghe.example.com", "secret-token", blobSrv.Client()) + bundle, err := f.Attestations().Download(context.Background(), "octocat", "hello", forge.Attestation{ + SubjectDigest: testAttestationDigest, + BundleURL: blobSrv.URL + "/attestations/1.json.sn?sig=signed", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "bundle", testBundle, string(bundle)) +} + +func TestGitHubDownloadAttestationInlineBundle(t *testing.T) { + svc := &gitHubAttestationService{client: github.NewClient(nil)} + bundle, err := svc.Download(context.Background(), "octocat", "hello", forge.Attestation{ + Bundle: []byte(testBundle), + BundleURL: "http://127.0.0.1:0/unreachable", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "bundle", testBundle, string(bundle)) +} + +func TestGitHubDownloadAttestationErrors(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + limit int + want error + }{ + { + name: "unauthorized", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + want: forge.ErrUnauthorized, + }, + { + name: "rate limited", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }, + want: forge.ErrRateLimited, + }, + { + name: "server error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + want: forge.ErrServerError, + }, + { + name: "not snappy", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) + }, + }, + { + name: "not json", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(snappy.Encode(nil, []byte("not json"))) + }, + }, + { + name: "decompressed too large", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(snappy.Encode(nil, []byte(strings.Repeat(" ", 1024)+testBundle))) + }, + limit: 512, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.limit > 0 { + old := maxBundleBytes + maxBundleBytes = tt.limit + t.Cleanup(func() { maxBundleBytes = old }) + } + srv := httptest.NewTLSServer(tt.handler) + defer srv.Close() + + svc := &gitHubAttestationService{client: github.NewClient(nil), httpClient: srv.Client()} + _, err := svc.Download(context.Background(), "octocat", "hello", forge.Attestation{ + BundleURL: srv.URL + "/1.json.sn?sig=signed-secret", + }) + if err == nil { + t.Fatal("expected an error") + } + if tt.want != nil && !errors.Is(err, tt.want) { + t.Errorf("expected %v, got %v", tt.want, err) + } + if strings.Contains(err.Error(), "signed-secret") { + t.Errorf("error leaks the signed bundle URL: %v", err) + } + }) + } +} + +func TestGitHubDownloadAttestationNoBundle(t *testing.T) { + svc := &gitHubAttestationService{client: github.NewClient(nil)} + if _, err := svc.Download(context.Background(), "octocat", "hello", forge.Attestation{}); err == nil { + t.Error("expected an error for an attestation without a bundle or bundle URL") + } +} + +func TestGitHubDownloadAttestationRejectsHTTPBundleURL(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write(snappy.Encode(nil, []byte(testBundle))) + })) + defer srv.Close() + + // The forge's own API is https, so a plain http bundle URL is refused. + f := NewWithBase("https://ghe.example.com", "secret-token", srv.Client()) + _, err := f.Attestations().Download(context.Background(), "octocat", "hello", forge.Attestation{ + BundleURL: srv.URL + "/1.json.sn?sig=signed-secret", + }) + if err == nil { + t.Fatal("expected an error for an http bundle URL") + } + if strings.Contains(err.Error(), "signed-secret") { + t.Errorf("error leaks the signed bundle URL: %v", err) + } + if hits.Load() != 0 { + t.Errorf("bundle server was contacted %d times", hits.Load()) + } +} + +func TestGitHubDownloadAttestationAllowsHTTPBundleURLOnHTTPForge(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(snappy.Encode(nil, []byte(testBundle))) + })) + defer srv.Close() + + f := NewWithBase(srv.URL, "secret-token", nil) + bundle, err := f.Attestations().Download(context.Background(), "octocat", "hello", forge.Attestation{ + BundleURL: srv.URL + "/1.json.sn?sig=signed", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "bundle", testBundle, string(bundle)) +} diff --git a/github/github.go b/github/github.go index 6c487f0..3f925ad 100644 --- a/github/github.go +++ b/github/github.go @@ -14,22 +14,31 @@ const defaultPageSize = 100 type gitHubForge struct { client *github.Client apiBaseURL string + // httpClient is the caller's client without the token transport, for + // requests that leave the API host. + httpClient *http.Client } // New creates a GitHub forge backend for github.com. +// The client is also used for requests that leave the API host, such as +// attestation bundle downloads, so credentials added by its transport reach +// those hosts too. func New(token string, hc *http.Client) forge.Forge { c := github.NewClient(hc) if token != "" { c = c.WithAuthToken(token) } - return &gitHubForge{client: c, apiBaseURL: "https://api.github.com"} + return &gitHubForge{client: c, apiBaseURL: "https://api.github.com", httpClient: hc} } // NewWithBase creates a GitHub forge backend for a GitHub Enterprise instance. +// The client is also used for requests that leave the API host, such as +// attestation bundle downloads, so credentials added by its transport reach +// those hosts too. func NewWithBase(baseURL, token string, hc *http.Client) forge.Forge { c := github.NewClient(hc).WithAuthToken(token) c, _ = c.WithEnterpriseURLs(baseURL, baseURL) - return &gitHubForge{client: c, apiBaseURL: strings.TrimRight(baseURL, "/") + "/api/v3"} + return &gitHubForge{client: c, apiBaseURL: strings.TrimRight(baseURL, "/") + "/api/v3", httpClient: hc} } func (f *gitHubForge) APIBaseURL() string { diff --git a/gitlab/attestations.go b/gitlab/attestations.go new file mode 100644 index 0000000..6b4d482 --- /dev/null +++ b/gitlab/attestations.go @@ -0,0 +1,112 @@ +//go:build !tinygo + +package gitlab + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + forge "github.com/git-pkgs/forge" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type gitLabAttestationService struct { + client *gitlab.Client +} + +func (f *gitLabForge) Attestations() forge.AttestationService { + return &gitLabAttestationService{client: f.client} +} + +func convertGitLabAttestation(a *gitlab.Attestation, subject string) forge.Attestation { + att := forge.Attestation{ + ID: strconv.FormatInt(a.IID, 10), + SubjectDigest: subject, + PredicateType: a.PredicateType, + Status: a.Status, + BundleURL: a.DownloadURL, + } + if a.CreatedAt != nil { + att.CreatedAt = *a.CreatedAt + } + if a.ExpireAt != nil { + att.ExpiresAt = *a.ExpireAt + } + return att +} + +// gitLabAttestationMatches applies the predicate type filter, which GitLab +// has no parameter for. The short names provenance and sbom match GitLab's +// predicate_kind, the same names GitHub's predicate_type parameter accepts. +func gitLabAttestationMatches(a *gitlab.Attestation, predicateType string) bool { + return predicateType == "" || a.PredicateType == predicateType || a.PredicateKind == predicateType +} + +// List returns ErrNotFound when the project does not exist, and also when the +// instance or project has the attestations API turned off, because GitLab +// answers 404 in both cases. +func (s *gitLabAttestationService) List(ctx context.Context, owner, repo, digest string, opts forge.ListAttestationOpts) ([]forge.Attestation, error) { + hex, err := forge.ParseSHA256Digest(digest) + if err != nil { + return nil, err + } + subject := "sha256:" + hex + pid := owner + "/" + repo + + reqOpts := []gitlab.RequestOptionFunc{gitlab.WithContext(ctx)} + var all []forge.Attestation + for { + atts, resp, err := s.client.Attestations.ListAttestations(pid, hex, reqOpts...) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, wrapErr(err) + } + for _, a := range atts { + if gitLabAttestationMatches(a, opts.PredicateType) { + all = append(all, convertGitLabAttestation(a, subject)) + } + } + if opts.Limit > 0 && len(all) >= opts.Limit { + break + } + next, ok := gitlab.WithNext(resp) + if !ok { + break + } + reqOpts = []gitlab.RequestOptionFunc{gitlab.WithContext(ctx), next} + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func (s *gitLabAttestationService) Download(ctx context.Context, owner, repo string, att forge.Attestation) (json.RawMessage, error) { + if len(att.Bundle) > 0 { + return att.Bundle, nil + } + iid, err := strconv.ParseInt(att.ID, 10, 64) + if err != nil || iid <= 0 { + return nil, fmt.Errorf("gitlab: invalid attestation ID %q", att.ID) + } + + bundle, resp, err := s.client.Attestations.DownloadAttestation(owner+"/"+repo, iid, gitlab.WithContext(ctx)) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, wrapErr(err) + } + if !json.Valid(bundle) { + return nil, errors.New("gitlab: attestation bundle is not valid JSON") + } + return bundle, nil +} diff --git a/gitlab/attestations_test.go b/gitlab/attestations_test.go new file mode 100644 index 0000000..8afd18a --- /dev/null +++ b/gitlab/attestations_test.go @@ -0,0 +1,281 @@ +//go:build !tinygo + +package gitlab + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + forge "github.com/git-pkgs/forge" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +const ( + testAttestationHex = "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" + testAttestationDigest = "sha256:" + testAttestationHex + testAttestationPath = "/api/v4/projects/group%2Fproject/attestations/" + testAttestationHex + testBundle = `{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","dsseEnvelope":{"payloadType":"application/vnd.in-toto+json"}}` +) + +func gitLabAttestationJSON(iid int, kind, predicateType string) string { + return fmt.Sprintf(`{ + "id": %d, "iid": %d, "project_id": 1, "build_id": 9, "status": "success", + "created_at": "2025-10-07T20:59:27.085Z", "updated_at": "2025-10-07T20:59:27.085Z", + "expire_at": "2027-10-07T20:59:26.967Z", + "predicate_kind": %q, "predicate_type": %q, "subject_digest": %q, + "download_url": "https://gitlab.example.com/api/v4/projects/1/attestations/%d/download" + }`, iid+100, iid, kind, predicateType, testAttestationHex, iid) +} + +func newTestGitLabAttestationService(t *testing.T, srv *httptest.Server) forge.AttestationService { + t.Helper() + c, err := gitlab.NewClient("test-token", gitlab.WithBaseURL(srv.URL+"/api/v4"), gitlab.WithoutRetries()) + if err != nil { + t.Fatalf("creating client: %v", err) + } + return &gitLabAttestationService{client: c} +} + +func TestGitLabListAttestationsPaginates(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "PRIVATE-TOKEN", "test-token", r.Header.Get("PRIVATE-TOKEN")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("page") { + case "": + w.Header().Set("X-Next-Page", "2") + _, _ = fmt.Fprintf(w, "[%s, %s]", + gitLabAttestationJSON(1, "provenance", "https://slsa.dev/provenance/v1"), + gitLabAttestationJSON(2, "sbom", "https://cyclonedx.org/bom")) + case "2": + _, _ = fmt.Fprintf(w, "[%s]", gitLabAttestationJSON(3, "provenance", "https://slsa.dev/provenance/v1")) + default: + t.Errorf("unexpected page %q", r.URL.Query().Get("page")) + } + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + upper := "sha256:" + strings.ToUpper(testAttestationHex) + atts, err := f.Attestations().List(context.Background(), "group", "project", upper, forge.ListAttestationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(atts) != 3 { + t.Fatalf("expected 3 attestations, got %d", len(atts)) + } + + assertEqual(t, "ID[0]", "1", atts[0].ID) + assertEqual(t, "SubjectDigest[0]", testAttestationDigest, atts[0].SubjectDigest) + assertEqual(t, "PredicateType[0]", "https://slsa.dev/provenance/v1", atts[0].PredicateType) + assertEqual(t, "Status[0]", "success", atts[0].Status) + assertEqual(t, "BundleURL[0]", "https://gitlab.example.com/api/v4/projects/1/attestations/1/download", atts[0].BundleURL) + if want := time.Date(2025, 10, 7, 20, 59, 27, 85000000, time.UTC); !atts[0].CreatedAt.Equal(want) { + t.Errorf("CreatedAt[0]: want %v, got %v", want, atts[0].CreatedAt) + } + if want := time.Date(2027, 10, 7, 20, 59, 26, 967000000, time.UTC); !atts[0].ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt[0]: want %v, got %v", want, atts[0].ExpiresAt) + } + if atts[0].Bundle != nil { + t.Errorf("Bundle[0]: want nil, got %s", atts[0].Bundle) + } + assertEqual(t, "ID[1]", "2", atts[1].ID) + assertEqual(t, "PredicateType[1]", "https://cyclonedx.org/bom", atts[1].PredicateType) + assertEqual(t, "ID[2]", "3", atts[2].ID) +} + +func TestGitLabListAttestationsPredicateTypeAndLimit(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, "[%s, %s, %s]", + gitLabAttestationJSON(1, "provenance", "https://slsa.dev/provenance/v1"), + gitLabAttestationJSON(2, "sbom", "https://cyclonedx.org/bom"), + gitLabAttestationJSON(3, "sbom", "https://spdx.dev/Document")) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + tests := []struct { + name string + opts forge.ListAttestationOpts + want []string + }{ + {"short name", forge.ListAttestationOpts{PredicateType: "sbom"}, []string{"2", "3"}}, + {"full URI", forge.ListAttestationOpts{PredicateType: "https://slsa.dev/provenance/v1"}, []string{"1"}}, + {"no match", forge.ListAttestationOpts{PredicateType: "https://example.com/other"}, nil}, + {"limit", forge.ListAttestationOpts{Limit: 2}, []string{"1", "2"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + atts, err := f.Attestations().List(context.Background(), "group", "project", testAttestationDigest, tt.opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var ids []string + for _, a := range atts { + ids = append(ids, a.ID) + } + assertSliceEqual(t, "IDs", tt.want, ids) + }) + } +} + +func TestGitLabListAttestationsEmpty(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[]`) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + atts, err := f.Attestations().List(context.Background(), "group", "project", testAttestationDigest, forge.ListAttestationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(atts) != 0 { + t.Errorf("expected no attestations, got %d", len(atts)) + } +} + +func TestGitLabListAttestationsErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + want error + }{ + // GitLab answers 404 when the slsa_provenance_statement flag is off. + {"feature unavailable", http.StatusNotFound, `{"message":"404 Not Found"}`, forge.ErrNotFound}, + {"unauthorized", http.StatusUnauthorized, `{"message":"401 Unauthorized"}`, forge.ErrUnauthorized}, + {"rate limited", http.StatusTooManyRequests, `{"message":"Retry later"}`, forge.ErrRateLimited}, + {"server error", http.StatusBadGateway, `{"message":"502 Bad Gateway"}`, forge.ErrServerError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = fmt.Fprint(w, tt.body) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newTestGitLabAttestationService(t, srv) + atts, err := svc.List(context.Background(), "group", "project", testAttestationDigest, forge.ListAttestationOpts{}) + if err == nil { + t.Fatalf("expected an error, got %d attestations", len(atts)) + } + if !errors.Is(err, tt.want) { + t.Errorf("expected %v, got %v", tt.want, err) + } + }) + } +} + +func TestGitLabListAttestationsInvalidDigest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL.Path) + })) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + _, err := f.Attestations().List(context.Background(), "group", "project", testAttestationHex, forge.ListAttestationOpts{}) + if !errors.Is(err, forge.ErrInvalidDigest) { + t.Errorf("expected ErrInvalidDigest, got %v", err) + } +} + +func TestGitLabListThenDownloadAttestation(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET "+testAttestationPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, "[%s]", gitLabAttestationJSON(3, "provenance", "https://slsa.dev/provenance/v1")) + }) + mux.HandleFunc("GET /api/v4/projects/group%2Fproject/attestations/3/download", func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "PRIVATE-TOKEN", "test-token", r.Header.Get("PRIVATE-TOKEN")) + w.Header().Set("Content-Type", "text/json") + _, _ = fmt.Fprint(w, testBundle) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + atts, err := f.Attestations().List(context.Background(), "group", "project", testAttestationDigest, forge.ListAttestationOpts{}) + if err != nil { + t.Fatalf("List: unexpected error: %v", err) + } + if len(atts) != 1 { + t.Fatalf("expected 1 attestation, got %d", len(atts)) + } + bundle, err := f.Attestations().Download(context.Background(), "group", "project", atts[0]) + if err != nil { + t.Fatalf("Download: unexpected error: %v", err) + } + assertEqual(t, "bundle", testBundle, string(bundle)) +} + +func TestGitLabDownloadAttestationErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + want error + }{ + {"not found", http.StatusNotFound, `{"message":"404 Not Found"}`, forge.ErrNotFound}, + {"unauthorized", http.StatusUnauthorized, `{"message":"401 Unauthorized"}`, forge.ErrUnauthorized}, + {"server error", http.StatusInternalServerError, `{"message":"500 Internal Server Error"}`, forge.ErrServerError}, + {"not json", http.StatusOK, `not json`, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/projects/group%2Fproject/attestations/3/download", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _, _ = fmt.Fprint(w, tt.body) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newTestGitLabAttestationService(t, srv) + bundle, err := svc.Download(context.Background(), "group", "project", forge.Attestation{ID: "3"}) + if err == nil { + t.Fatalf("expected an error, got bundle %s", bundle) + } + if tt.want != nil && !errors.Is(err, tt.want) { + t.Errorf("expected %v, got %v", tt.want, err) + } + }) + } +} + +func TestGitLabDownloadAttestationInvalidID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL.Path) + })) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + for _, id := range []string{"", "0", "-1", "abc", "1/../2"} { + if _, err := f.Attestations().Download(context.Background(), "group", "project", forge.Attestation{ID: id}); err == nil { + t.Errorf("Download with ID %q: expected an error", id) + } + } +} diff --git a/gitlab/gitlab_tinygo.go b/gitlab/gitlab_tinygo.go index 547abe6..03bdca2 100644 --- a/gitlab/gitlab_tinygo.go +++ b/gitlab/gitlab_tinygo.go @@ -4,6 +4,7 @@ package gitlab import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -120,6 +121,18 @@ func (s *gitLabCommitService) ResolveCommit(ctx context.Context, owner, repo, re return "", errTinyGo } +type gitLabAttestationService struct{} + +func (f *gitLabForge) Attestations() forge.AttestationService { return &gitLabAttestationService{} } + +func (s *gitLabAttestationService) List(ctx context.Context, owner, repo, digest string, opts forge.ListAttestationOpts) ([]forge.Attestation, error) { + return nil, errTinyGo +} + +func (s *gitLabAttestationService) Download(ctx context.Context, owner, repo string, att forge.Attestation) (json.RawMessage, error) { + return nil, errTinyGo +} + type gitLabDeployKeyService struct{} func (f *gitLabForge) DeployKeys() forge.DeployKeyService { return &gitLabDeployKeyService{} } diff --git a/gitlab/gitlab_tinygo_test.go b/gitlab/gitlab_tinygo_test.go index 9eb91d6..850f87b 100644 --- a/gitlab/gitlab_tinygo_test.go +++ b/gitlab/gitlab_tinygo_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "net/http" + "strings" "testing" forge "github.com/git-pkgs/forge" @@ -72,6 +73,10 @@ func TestTinyGoServicesReturnUnsupported(t *testing.T) { }}, {"commit statuses", func() error { _, err := f.CommitStatuses().List(ctx, "group", "project", "main"); return err }}, {"commits", func() error { _, err := f.Commits().ResolveCommit(ctx, "group", "project", "main"); return err }}, + {"attestations", func() error { + _, err := f.Attestations().List(ctx, "group", "project", "sha256:"+strings.Repeat("a", 64), forge.ListAttestationOpts{}) + return err + }}, {"rate limit", func() error { _, err := f.GetRateLimit(ctx); return err }}, {"authenticated user", func() error { _, err := f.GetAuthenticatedUser(ctx); return err }}, } diff --git a/go.mod b/go.mod index 7871460..d29ed5a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.26.7 require ( code.gitea.io/sdk/gitea v0.25.1 github.com/git-pkgs/purl v0.1.24 + github.com/golang/snappy v1.0.0 github.com/google/go-github/v82 v82.0.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 8cd5853..1d88c09 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/git-pkgs/vers v0.7.2 h1:Ctex1O+Ai/FONmyDaw37EolBXg0og6UdxbCjjYbeRHs= github.com/git-pkgs/vers v0.7.2/go.mod h1:ofLiBpPNkQmC0LB1k0zmN1gCv7Hi3MiZx8WtmWZ4A9A= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/internal/cli/pr_checkout_test.go b/internal/cli/pr_checkout_test.go index 87828f0..ed24bbf 100644 --- a/internal/cli/pr_checkout_test.go +++ b/internal/cli/pr_checkout_test.go @@ -111,6 +111,7 @@ func (m *mockForge) Collaborators() forges.CollaboratorService { return nil } func (m *mockForge) CommitStatuses() forges.CommitStatusService { return m.commitStatusService } func (m *mockForge) CheckRuns() forges.CheckRunService { return m.checkRunService } func (m *mockForge) Commits() forges.CommitService { return nil } +func (m *mockForge) Attestations() forges.AttestationService { return nil } func (m *mockForge) GetRateLimit(_ context.Context) (*forges.RateLimit, error) { return nil, forges.ErrNotSupported } diff --git a/radicle/radicle.go b/radicle/radicle.go index 6d2b980..d81aef8 100644 --- a/radicle/radicle.go +++ b/radicle/radicle.go @@ -66,6 +66,9 @@ func (f *radicleForge) CommitStatuses() forges.CommitStatusService { } func (f *radicleForge) CheckRuns() forges.CheckRunService { return unsupportedCheckRunService{} } func (f *radicleForge) Commits() forges.CommitService { return unsupportedCommitService{} } +func (f *radicleForge) Attestations() forges.AttestationService { + return unsupportedAttestationService{} +} func (f *radicleForge) GetRateLimit(context.Context) (*forges.RateLimit, error) { return nil, forges.ErrNotSupported diff --git a/radicle/radicle_test.go b/radicle/radicle_test.go index c0ed7df..e0631af 100644 --- a/radicle/radicle_test.go +++ b/radicle/radicle_test.go @@ -547,6 +547,9 @@ func TestUnsupportedServices(t *testing.T) { if _, err := f.Commits().ResolveCommit(ctx, "", testRID, "main"); !errors.Is(err, forges.ErrNotSupported) { t.Errorf("Commits().ResolveCommit error = %v, want ErrNotSupported", err) } + if _, err := f.Attestations().List(ctx, "", testRID, "sha256:"+strings.Repeat("a", 64), forges.ListAttestationOpts{}); !errors.Is(err, forges.ErrNotSupported) { + t.Errorf("Attestations().List error = %v, want ErrNotSupported", err) + } if _, err := f.GetAuthenticatedUser(ctx); !errors.Is(err, forges.ErrNotSupported) { t.Errorf("GetAuthenticatedUser error = %v, want ErrNotSupported", err) } diff --git a/radicle/unsupported.go b/radicle/unsupported.go index f5f4c3d..e73e1c2 100644 --- a/radicle/unsupported.go +++ b/radicle/unsupported.go @@ -2,6 +2,7 @@ package radicle import ( "context" + "encoding/json" "io" "net/http" "os" @@ -299,3 +300,12 @@ type unsupportedCommitService struct{} func (unsupportedCommitService) ResolveCommit(context.Context, string, string, string) (string, error) { return "", forges.ErrNotSupported } + +type unsupportedAttestationService struct{} + +func (unsupportedAttestationService) List(context.Context, string, string, string, forges.ListAttestationOpts) ([]forges.Attestation, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedAttestationService) Download(context.Context, string, string, forges.Attestation) (json.RawMessage, error) { + return nil, forges.ErrNotSupported +} diff --git a/tangled/tangled.go b/tangled/tangled.go index 1a5fefb..e1b13be 100644 --- a/tangled/tangled.go +++ b/tangled/tangled.go @@ -66,6 +66,9 @@ func (f *tangledForge) CommitStatuses() forges.CommitStatusService { } func (f *tangledForge) CheckRuns() forges.CheckRunService { return unsupportedCheckRunService{} } func (f *tangledForge) Commits() forges.CommitService { return unsupportedCommitService{} } +func (f *tangledForge) Attestations() forges.AttestationService { + return unsupportedAttestationService{} +} func (f *tangledForge) GetRateLimit(context.Context) (*forges.RateLimit, error) { return nil, forges.ErrNotSupported diff --git a/tangled/tangled_test.go b/tangled/tangled_test.go index b3d9a1e..0e04588 100644 --- a/tangled/tangled_test.go +++ b/tangled/tangled_test.go @@ -187,6 +187,18 @@ func TestGetAuthenticatedUserUnsupported(t *testing.T) { } } +func TestAttestationsUnsupported(t *testing.T) { + ctx := context.Background() + a := New("https://tangled.org", "", nil).Attestations() + + if _, err := a.List(ctx, "tangled.org", "core", "sha256:"+strings.Repeat("a", 64), forges.ListAttestationOpts{}); !errors.Is(err, forges.ErrNotSupported) { + t.Errorf("List: expected ErrNotSupported, got %v", err) + } + if _, err := a.Download(ctx, "tangled.org", "core", forges.Attestation{ID: "1"}); !errors.Is(err, forges.ErrNotSupported) { + t.Errorf("Download: expected ErrNotSupported, got %v", err) + } +} + func TestSubscriptionsUnsupported(t *testing.T) { ctx := context.Background() n := New("https://tangled.org", "", nil).Notifications() diff --git a/tangled/unsupported.go b/tangled/unsupported.go index cb8c162..0ea7e3d 100644 --- a/tangled/unsupported.go +++ b/tangled/unsupported.go @@ -2,6 +2,7 @@ package tangled import ( "context" + "encoding/json" "io" "net/http" "os" @@ -299,3 +300,12 @@ type unsupportedCommitService struct{} func (unsupportedCommitService) ResolveCommit(context.Context, string, string, string) (string, error) { return "", forges.ErrNotSupported } + +type unsupportedAttestationService struct{} + +func (unsupportedAttestationService) List(context.Context, string, string, string, forges.ListAttestationOpts) ([]forges.Attestation, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedAttestationService) Download(context.Context, string, string, forges.Attestation) (json.RawMessage, error) { + return nil, forges.ErrNotSupported +}