From ecba561f0f1dde330a26520c6558c9e068d7d8ce Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Tue, 1 Sep 2026 10:02:25 -0400 Subject: [PATCH] Treat a missing notes ref as no attestations Signed-off-by: Arpit Jain --- pkg/attest/provenance.go | 28 +++++++++++++- pkg/attest/provenance_notesref_test.go | 53 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 pkg/attest/provenance_notesref_test.go diff --git a/pkg/attest/provenance.go b/pkg/attest/provenance.go index f7ef4cb..309d758 100644 --- a/pkg/attest/provenance.go +++ b/pkg/attest/provenance.go @@ -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" @@ -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 { @@ -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) } @@ -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) } diff --git a/pkg/attest/provenance_notesref_test.go b/pkg/attest/provenance_notesref_test.go new file mode 100644 index 0000000..7f0abf5 --- /dev/null +++ b/pkg/attest/provenance_notesref_test.go @@ -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) + } + }) + } +}