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
28 changes: 26 additions & 2 deletions pkg/attest/provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/carabiner-dev/collector/filters"
"github.com/carabiner-dev/collector/repository/github"
"github.com/carabiner-dev/collector/repository/note"
"github.com/go-git/go-git/v5"
vsa "github.com/in-toto/attestation/go/predicates/vsa/v1"
intoto "github.com/in-toto/attestation/go/v1"
"google.golang.org/protobuf/encoding/protojson"
Expand Down Expand Up @@ -151,6 +152,21 @@ func (a *Attester) getCollector(branch *models.Branch) (*collector.Agent, error)
return agent, nil
}

// notesRefMissing reports whether an attestation fetch failed only because the
// git notes ref does not exist yet. A repository that has never been attested
// has no such ref, so this is an empty attestation set rather than an error:
// without it the first attestation can never be written, because writing it
// requires reading the attestations that would have created the ref.
func notesRefMissing(err error) bool {
if errors.Is(err, git.NoMatchingRefSpecError{}) {
return true
}
// The collector aggregates per-locator failures in a vcslocator.ErrorList,
// which has no Unwrap, so on that path the go-git error is only reachable
// through the message.
return strings.Contains(err.Error(), "couldn't find remote ref")
}

// GetRevisionVSA returns a revision's VSA attestation
func (a *Attester) GetRevisionVSA(ctx context.Context, branch *models.Branch, revision models.Revision) (attestation.Envelope, *vsa.VerificationSummary, error) {
if revision.GetCommit() == nil {
Expand All @@ -176,12 +192,16 @@ func (a *Attester) GetRevisionVSA(ctx context.Context, branch *models.Branch, re
ctx, []attestation.Subject{revision.GetCommit().ToResourceDescriptor()},
collector.WithQuery(attestation.NewQuery().WithFilter(matcher)),
)
if attErr == nil {
if attErr == nil || notesRefMissing(attErr) {
break
}
time.Sleep(time.Duration(i*5) * time.Second)
}
if attErr != nil {
if notesRefMissing(attErr) {
Debugf("notes ref not created yet, treating as no attestations")
return nil, nil, nil
}
return nil, nil, fmt.Errorf("fetching attestations: %w", attErr)
}

Expand Down Expand Up @@ -257,12 +277,16 @@ func (a *Attester) GetRevisionProvenance(ctx context.Context, branch *models.Bra
ctx, []attestation.Subject{commit.ToResourceDescriptor()},
collector.WithQuery(attestation.NewQuery().WithFilter(matcher)),
)
if attErr == nil {
if attErr == nil || notesRefMissing(attErr) {
break
}
time.Sleep(time.Duration(i*5) * time.Second)
}
if attErr != nil {
if notesRefMissing(attErr) {
Debugf("notes ref not created yet, treating as no attestations")
return nil, nil
}
return nil, fmt.Errorf("fetching attestations: %w", attErr)
}

Expand Down
53 changes: 53 additions & 0 deletions pkg/attest/provenance_notesref_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: Copyright 2026 The SLSA Authors
// SPDX-License-Identifier: Apache-2.0

package attest

import (
"errors"
"fmt"
"testing"

"github.com/go-git/go-git/v5"
)

// errorList stands in for vcslocator.ErrorList, which renders its members but
// does not implement Unwrap, so errors.Is cannot see through it.
type errorList struct {
errs []error
}

func (e *errorList) Error() string {
return errors.Join(e.errs...).Error()
}

func TestNotesRefMissing(t *testing.T) {
fetchFailure := fmt.Errorf("fetching ref %q: %w", "refs/notes/commits", git.NoMatchingRefSpecError{})
// The same failure as it renders once the aggregate has flattened it to
// text: go-git's NoMatchingRefSpecError prints the ref it could not find.
rendered := errors.New(`fetching ref "refs/notes/commits": couldn't find remote ref "refs/notes/commits"`)

for name, tc := range map[string]struct {
err error
want bool
}{
"wrapped go-git error": {
err: fmt.Errorf("fetching attestations: %w", fetchFailure),
want: true,
},
"aggregated without an unwrap": {
err: fmt.Errorf("error cloning repositories: %w", &errorList{errs: []error{rendered, nil}}),
want: true,
},
"some other failure": {
err: fmt.Errorf("fetching attestations: %w", errors.New("connection refused")),
want: false,
},
} {
t.Run(name, func(t *testing.T) {
if got := notesRefMissing(tc.err); got != tc.want {
t.Errorf("notesRefMissing(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}