From 007bc054412a84dc4e56f6d3bdf3634ced681dfb Mon Sep 17 00:00:00 2001 From: robert-cronin Date: Wed, 29 Jan 2025 22:51:37 +0000 Subject: [PATCH] Add reference attestation for multiple equivalent images Signed-off-by: robert-cronin --- internal/testing/testdata/models.go | 78 +++++++++ .../reference/attestation_reference.go | 54 ++++++ pkg/handler/collector/oci/oci.go | 73 +++++++- pkg/handler/processor/ite6/ite6.go | 3 +- pkg/handler/processor/process/process.go | 1 + pkg/handler/processor/processor.go | 9 +- pkg/ingestor/parser/parser.go | 2 + pkg/ingestor/parser/reference/reference.go | 162 +++++++++++++++++ .../parser/reference/reference_test.go | 164 ++++++++++++++++++ 9 files changed, 539 insertions(+), 7 deletions(-) create mode 100644 pkg/certifier/attestation/reference/attestation_reference.go create mode 100644 pkg/ingestor/parser/reference/reference.go create mode 100644 pkg/ingestor/parser/reference/reference_test.go diff --git a/internal/testing/testdata/models.go b/internal/testing/testdata/models.go index 706d861f24..f0f09f55b0 100644 --- a/internal/testing/testdata/models.go +++ b/internal/testing/testdata/models.go @@ -608,3 +608,81 @@ var ITE6EOLPython = []byte(`{ } } }`) + +// ITE6ReferenceSingle is a test document for the Reference ingestor with a single reference +var ITE6ReferenceSingle = []byte(`{ + "type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "uri": "pkg:npm/example-pkg@1.0.0" + } + ], + "predicateType": "https://in-toto.io/attestation/reference/v0.1", + "predicate": { + "attester": { + "id": "attester-123" + }, + "references": [ + { + "downloadLocation": "https://example.com/downloads/pkg.tar.gz", + "digest": { + "sha256": "abcd1234..." + }, + "mediaType": "application/x-tar" + } + ] + } +}`) + +// ITE6ReferenceMultiple is a test document for the Reference ingestor with multiple references +var ITE6ReferenceMultiple = []byte(`{ + "type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "uri": "pkg:pypi/example-python@3.9.0" + } + ], + "predicateType": "https://in-toto.io/attestation/reference/v0.1", + "predicate": { + "attester": { + "id": "attester-xyz" + }, + "references": [ + { + "downloadLocation": "https://example.com/artifacts/python-ref1.tgz", + "digest": { + "sha256": "aa1111111111111111111111111111111111111111111111111111111111111111" + }, + "mediaType": "application/octet-stream" + }, + { + "downloadLocation": "https://example.com/artifacts/python-ref2.whl", + "digest": { + "sha256": "bb2222222222222222222222222222222222222222222222222222222222222222" + }, + "mediaType": "application/zip" + } + ] + } +}`) + +// ITE6ReferenceNoSubject is a test document for the Reference ingestor with no subject provided +var ITE6ReferenceNoSubject = []byte(`{ + "type": "https://in-toto.io/Statement/v1", + "subject": [], + "predicateType": "https://in-toto.io/attestation/reference/v0.1", + "predicate": { + "attester": { + "id": "attester-nobody" + }, + "references": [ + { + "downloadLocation": "https://example.com/artifacts/no-subject.tgz", + "digest": { + "sha256": "no-subject-digest" + }, + "mediaType": "application/octet-stream" + } + ] + } +}`) diff --git a/pkg/certifier/attestation/reference/attestation_reference.go b/pkg/certifier/attestation/reference/attestation_reference.go new file mode 100644 index 0000000000..3916110dd2 --- /dev/null +++ b/pkg/certifier/attestation/reference/attestation_reference.go @@ -0,0 +1,54 @@ +// +// Copyright 2025 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package attestation + +import ( + attestationv1 "github.com/in-toto/attestation/go/v1" +) + +const ( + PredicateReference = "https://in-toto.io/attestation/reference/v0.1" +) + +// ReferenceStatement defines the statement header and the Reference predicate +type ReferenceStatement struct { + attestationv1.Statement + // Predicate contains type specific metadata. + Predicate ReferencePredicate `json:"predicate"` +} + +// ReferencePredicate defines predicate definition of the Reference attestation +type ReferencePredicate struct { + Attester ReferenceAttester `json:"attester"` + References []ReferenceItem `json:"references"` +} + +// ReferenceAttester defines the attester information +type ReferenceAttester struct { + ID string `json:"id"` +} + +// ReferenceItem represents an individual reference in the predicate +type ReferenceItem struct { + DownloadLocation string `json:"downloadLocation"` + Digest ReferenceDigestItem `json:"digest"` + MediaType string `json:"mediaType"` +} + +// ReferenceDigestItem represents an individual digest in the predicate +type ReferenceDigestItem struct { + SHA256 string `json:"sha256"` +} diff --git a/pkg/handler/collector/oci/oci.go b/pkg/handler/collector/oci/oci.go index 65a5f59048..3e7075d147 100644 --- a/pkg/handler/collector/oci/oci.go +++ b/pkg/handler/collector/oci/oci.go @@ -17,17 +17,21 @@ package oci import ( "context" + "encoding/json" "fmt" "slices" "strings" "sync" "time" + attestation "github.com/guacsec/guac/pkg/certifier/attestation/reference" "github.com/guacsec/guac/pkg/collectsub/datasource" "github.com/guacsec/guac/pkg/events" "github.com/guacsec/guac/pkg/handler/processor" "github.com/guacsec/guac/pkg/logging" "github.com/guacsec/guac/pkg/version" + attestationv1 "github.com/in-toto/attestation/go/v1" + "github.com/opencontainers/go-digest" "github.com/pkg/errors" "github.com/regclient/regclient" "github.com/regclient/regclient/types/descriptor" @@ -322,7 +326,7 @@ func (o *ociCollector) fetchFallbackArtifacts(ctx context.Context, repo string, // check to see if the digest + suffix has already been collected if !o.isDigestCollected(repo, digestTag) { imageTag := fmt.Sprintf("%v:%v", repo, digestTag) - err := fetchOCIArtifactBlobs(ctx, rc, imageTag, "unknown", docChannel) + err := fetchOCIArtifactBlobs(ctx, rc, image, imageTag, "unknown", docChannel) if err != nil { return fmt.Errorf("failed retrieving artifact blobs from registry fallback artifacts: %w", err) } @@ -365,7 +369,7 @@ func (o *ociCollector) fetchReferrerArtifacts(ctx context.Context, repo string, if !o.isDigestCollected(repo, referrerDescDigest) { logger.Infof("Fetching referrer %s with artifact type %s", referrerDescDigest, referrerDesc.ArtifactType) referrerDigest := fmt.Sprintf("%v@%v", repo, referrerDescDigest) - e := fetchOCIArtifactBlobs(ctx, rc, referrerDigest, referrerDesc.ArtifactType, docChannel) + e := fetchOCIArtifactBlobs(ctx, rc, image, referrerDigest, referrerDesc.ArtifactType, docChannel) if e != nil { errorChan <- fmt.Errorf("failed retrieving artifact blobs from registry: %w", err) cancel() @@ -403,6 +407,7 @@ func (o *ociCollector) fetchReferrerArtifacts(ctx context.Context, repo string, func fetchOCIArtifactBlobs( ctx context.Context, rc *regclient.RegClient, + image ref.Ref, artifact, artifactType string, docChannel chan<- *processor.Document, @@ -458,6 +463,12 @@ func fetchOCIArtifactBlobs( } } + err = checkIfImageIsCopy(image, artifact, btr1, docChannel) + if err != nil { + // log error and continue + logger.Errorf("failed to check if blob is occurrence: %v", err) + } + doc := &processor.Document{ Blob: btr1, Type: docType, @@ -474,6 +485,64 @@ func fetchOCIArtifactBlobs( return nil } +func checkIfImageIsCopy( + image ref.Ref, + artifact string, + blob []byte, + docChannel chan<- *processor.Document, +) error { + spdxDigest := digest.FromBytes(blob) + imagePurl := fmt.Sprintf("pkg:oci/%s/%s@%s", image.Registry, image.Repository, image.Digest) + + artifactSyncMockAttesterID := "https://artifact-sync.azure.com/v1" + referenceStatement := &attestation.ReferenceStatement{ + Statement: attestationv1.Statement{ + Type: attestationv1.StatementTypeUri, + PredicateType: attestation.PredicateReference, + Subject: []*attestationv1.ResourceDescriptor{{ + Uri: imagePurl, + Digest: map[string]string{ + "sha256": image.Digest, + }, + }}, + }, + Predicate: attestation.ReferencePredicate{ + Attester: attestation.ReferenceAttester{ + ID: artifactSyncMockAttesterID, + }, + References: []attestation.ReferenceItem{ + { + DownloadLocation: artifact, + Digest: attestation.ReferenceDigestItem{ + SHA256: spdxDigest.String(), + }, + MediaType: SpdxJson, + }, + }, + }, + } + + // marshall the reference statement + referenceStatementBytes, err := json.Marshal(referenceStatement) + if err != nil { + return fmt.Errorf("failed to marshal reference statement: %w", err) + } + + doc := &processor.Document{ + Blob: referenceStatementBytes, + Type: processor.DocumentITE6Reference, + Format: processor.FormatJSON, + SourceInformation: processor.SourceInformation{ + Collector: string(OCICollector), + Source: artifact, + DocumentRef: events.GetDocRef(referenceStatementBytes), + }, + } + docChannel <- doc + + return nil +} + // isDigestCollected checks if a given digest has already been collected for a given repository. // It returns true if the digest has been collected, false otherwise. func (o *ociCollector) isDigestCollected(repo string, digest string) bool { diff --git a/pkg/handler/processor/ite6/ite6.go b/pkg/handler/processor/ite6/ite6.go index 555c6c7bfb..b974ebafdc 100644 --- a/pkg/handler/processor/ite6/ite6.go +++ b/pkg/handler/processor/ite6/ite6.go @@ -35,7 +35,8 @@ func (e *ITE6Processor) ValidateSchema(i *processor.Document) error { i.Type != processor.DocumentITE6SLSA && i.Type != processor.DocumentITE6Vul && i.Type != processor.DocumentITE6ClearlyDefined && - i.Type != processor.DocumentITE6EOL { + i.Type != processor.DocumentITE6EOL && + i.Type != processor.DocumentITE6Reference { return fmt.Errorf("expected ITE6 document type, actual document type: %v", i.Type) } diff --git a/pkg/handler/processor/process/process.go b/pkg/handler/processor/process/process.go index 499f0c8682..e522cf0618 100644 --- a/pkg/handler/processor/process/process.go +++ b/pkg/handler/processor/process/process.go @@ -60,6 +60,7 @@ func init() { _ = RegisterDocumentProcessor(&ite6.ITE6Processor{}, processor.DocumentITE6Vul) _ = RegisterDocumentProcessor(&ite6.ITE6Processor{}, processor.DocumentITE6ClearlyDefined) _ = RegisterDocumentProcessor(&ite6.ITE6Processor{}, processor.DocumentITE6EOL) + _ = RegisterDocumentProcessor(&ite6.ITE6Processor{}, processor.DocumentITE6Reference) _ = RegisterDocumentProcessor(&dsse.DSSEProcessor{}, processor.DocumentDSSE) _ = RegisterDocumentProcessor(&spdx.SPDXProcessor{}, processor.DocumentSPDX) _ = RegisterDocumentProcessor(&csaf.CSAFProcessor{}, processor.DocumentCsaf) diff --git a/pkg/handler/processor/processor.go b/pkg/handler/processor/processor.go index 100bfb577a..4004177398 100644 --- a/pkg/handler/processor/processor.go +++ b/pkg/handler/processor/processor.go @@ -56,10 +56,11 @@ type DocumentType string // Document* is the enumerables of DocumentType const ( - DocumentITE6SLSA DocumentType = "SLSA" - DocumentITE6Generic DocumentType = "ITE6" - DocumentITE6Vul DocumentType = "ITE6VUL" - DocumentITE6EOL DocumentType = "ITE6EOL" + DocumentITE6SLSA DocumentType = "SLSA" + DocumentITE6Generic DocumentType = "ITE6" + DocumentITE6Vul DocumentType = "ITE6VUL" + DocumentITE6EOL DocumentType = "ITE6EOL" + DocumentITE6Reference DocumentType = "ITE6REF" // ClearlyDefined DocumentITE6ClearlyDefined DocumentType = "ITE6CD" DocumentDSSE DocumentType = "DSSE" diff --git a/pkg/ingestor/parser/parser.go b/pkg/ingestor/parser/parser.go index 884185f986..ad799bc432 100644 --- a/pkg/ingestor/parser/parser.go +++ b/pkg/ingestor/parser/parser.go @@ -32,6 +32,7 @@ import ( "github.com/guacsec/guac/pkg/ingestor/parser/eol" "github.com/guacsec/guac/pkg/ingestor/parser/opaque" "github.com/guacsec/guac/pkg/ingestor/parser/open_vex" + "github.com/guacsec/guac/pkg/ingestor/parser/reference" "github.com/guacsec/guac/pkg/ingestor/parser/scorecard" "github.com/guacsec/guac/pkg/ingestor/parser/slsa" "github.com/guacsec/guac/pkg/ingestor/parser/spdx" @@ -50,6 +51,7 @@ func init() { _ = RegisterDocumentParser(csaf.NewCsafParser, processor.DocumentCsaf) _ = RegisterDocumentParser(open_vex.NewOpenVEXParser, processor.DocumentOpenVEX) _ = RegisterDocumentParser(eol.NewEOLCertificationParser, processor.DocumentITE6EOL) + _ = RegisterDocumentParser(reference.NewReferenceParser, processor.DocumentITE6Reference) _ = RegisterDocumentParser(opaque.NewOpaqueParser, processor.DocumentOpaque) } diff --git a/pkg/ingestor/parser/reference/reference.go b/pkg/ingestor/parser/reference/reference.go new file mode 100644 index 0000000000..0cc88a61ca --- /dev/null +++ b/pkg/ingestor/parser/reference/reference.go @@ -0,0 +1,162 @@ +// +// Copyright 2025 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reference + +import ( + "context" + "fmt" + "time" + + jsoniter "github.com/json-iterator/go" + + "github.com/guacsec/guac/pkg/assembler" + "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/assembler/helpers" + attestation "github.com/guacsec/guac/pkg/certifier/attestation/reference" + "github.com/guacsec/guac/pkg/handler/processor" + "github.com/guacsec/guac/pkg/ingestor/parser/common" + "github.com/guacsec/guac/pkg/logging" +) + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +const ( + justification = "Retrieved from reference predicate" +) + +type parser struct { + doc *processor.Document + pkg *generated.PkgInputSpec + collectedReference []assembler.IsOccurrenceIngest + identifierStrings *common.IdentifierStrings + timeScanned time.Time +} + +// newReferenceParser initializes the parser +func NewReferenceParser() common.DocumentParser { + return &parser{ + identifierStrings: &common.IdentifierStrings{}, + } +} + +// initializeReferenceParser clears out all values for the next iteration +func (r *parser) initializeReferenceParser() { + r.doc = nil + r.pkg = nil + r.collectedReference = make([]assembler.IsOccurrenceIngest, 0) + r.identifierStrings = &common.IdentifierStrings{} + r.timeScanned = time.Now() +} + +// Parse breaks out the document into the graph components +func (r *parser) Parse(ctx context.Context, doc *processor.Document) error { + logger := logging.FromContext(ctx) + r.initializeReferenceParser() + r.doc = doc + + statement, err := parseReferenceStatement(doc.Blob) + if err != nil { + return fmt.Errorf("failed to parse reference predicate: %w", err) + } + + r.timeScanned = time.Now() + + if err := r.parseSubject(statement); err != nil { + logger.Warnf("unable to parse subject of statement: %v", err) + return fmt.Errorf("unable to parse subject of statement: %w", err) + } + + if err := r.parseReferences(ctx, statement); err != nil { + logger.Warnf("unable to parse reference statement: %v", err) + return fmt.Errorf("unable to parse reference statement: %w", err) + } + + return nil +} + +func parseReferenceStatement(p []byte) (*attestation.ReferenceStatement, error) { + statement := attestation.ReferenceStatement{} + if err := json.Unmarshal(p, &statement); err != nil { + return nil, fmt.Errorf("failed to unmarshal reference predicate: %w", err) + } + return &statement, nil +} + +func (r *parser) parseSubject(s *attestation.ReferenceStatement) error { + if len(s.Statement.Subject) == 0 { + return fmt.Errorf("no subject found in reference statement") + } + + for _, sub := range s.Statement.Subject { + p, err := helpers.PurlToPkg(sub.Uri) + if err != nil { + return fmt.Errorf("failed to parse uri: %s to a package with error: %w", sub.Uri, err) + } + r.pkg = p + r.identifierStrings.PurlStrings = append(r.identifierStrings.PurlStrings, sub.Uri) + } + return nil +} + +// parseReferences parses the attestation to collect the reference information +func (r *parser) parseReferences(_ context.Context, s *attestation.ReferenceStatement) error { + if r.pkg == nil { + return fmt.Errorf("package not specified for reference information") + } + + for _, ref := range s.Predicate.References { + refData := assembler.IsOccurrenceIngest{ + Pkg: r.pkg, + Artifact: &generated.ArtifactInputSpec{ + Algorithm: "sha256", + Digest: ref.Digest.SHA256, + }, + IsOccurrence: &generated.IsOccurrenceInputSpec{ + Justification: justification, + Collector: "GUAC", + Origin: "GUAC Reference", + DocumentRef: ref.DownloadLocation, + }, + } + + r.collectedReference = append(r.collectedReference, refData) + } + + return nil +} + +func (r *parser) GetPredicates(ctx context.Context) *assembler.IngestPredicates { + logger := logging.FromContext(ctx) + preds := &assembler.IngestPredicates{} + + if r.pkg == nil { + logger.Error("error getting predicates: unable to find package element") + return preds + } + + preds.IsOccurrence = r.collectedReference + return preds +} + +// GetIdentities gets the identity node from the document if they exist +func (r *parser) GetIdentities(ctx context.Context) []common.TrustInformation { + return nil +} + +func (r *parser) GetIdentifiers(ctx context.Context) (*common.IdentifierStrings, error) { + common.RemoveDuplicateIdentifiers(r.identifierStrings) + return r.identifierStrings, nil +} diff --git a/pkg/ingestor/parser/reference/reference_test.go b/pkg/ingestor/parser/reference/reference_test.go new file mode 100644 index 0000000000..277fabada1 --- /dev/null +++ b/pkg/ingestor/parser/reference/reference_test.go @@ -0,0 +1,164 @@ +// +// Copyright 2025 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reference + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/guacsec/guac/internal/testing/ptrfrom" + "github.com/guacsec/guac/internal/testing/testdata" + "github.com/guacsec/guac/pkg/assembler" + "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/handler/processor" + "github.com/guacsec/guac/pkg/logging" +) + +func TestReferenceParser(t *testing.T) { + ctx := logging.WithLogger(context.Background()) + tm, _ := time.Parse(time.RFC3339, "2025-01-23T12:00:00Z") + + tests := []struct { + name string + doc *processor.Document + wantHasMet []assembler.HasMetadataIngest + wantErr bool + }{ + { + name: "valid reference data with single reference", + doc: &processor.Document{ + Blob: testdata.ITE6ReferenceSingle, + Format: processor.FormatJSON, + Type: processor.DocumentITE6Reference, + SourceInformation: processor.SourceInformation{ + Collector: "TestCollector", + Source: "TestSource", + }, + }, + wantHasMet: []assembler.HasMetadataIngest{ + { + Pkg: &generated.PkgInputSpec{ + Type: "npm", + Name: "example-pkg", + Version: ptrfrom.String("1.0.0"), + Namespace: ptrfrom.String(""), + Subpath: ptrfrom.String(""), + }, + PkgMatchFlag: generated.MatchFlags{Pkg: generated.PkgMatchTypeSpecificVersion}, + HasMetadata: &generated.HasMetadataInputSpec{ + Key: "reference", + Value: "attester:attester-123,ref#0,downloadLocation:https://example.com/downloads/pkg.tar.gz,digest:sha256=abcd1234...,mediaType:application/x-tar", + Timestamp: tm, + Justification: "Retrieved from reference predicate", + Origin: "GUAC Reference Certifier", + Collector: "GUAC", + }, + }, + }, + wantErr: false, + }, + { + name: "valid reference data with multiple references", + doc: &processor.Document{ + Blob: testdata.ITE6ReferenceMultiple, + Format: processor.FormatJSON, + Type: processor.DocumentITE6Reference, + SourceInformation: processor.SourceInformation{ + Collector: "TestCollector", + Source: "TestSource", + }, + }, + wantHasMet: []assembler.HasMetadataIngest{ + { + Pkg: &generated.PkgInputSpec{ + Type: "pypi", + Name: "example-python", + Version: ptrfrom.String("3.9.0"), + Namespace: ptrfrom.String(""), + Subpath: ptrfrom.String(""), + }, + PkgMatchFlag: generated.MatchFlags{Pkg: generated.PkgMatchTypeSpecificVersion}, + HasMetadata: &generated.HasMetadataInputSpec{ + Key: "reference", + Value: "attester:attester-xyz,ref#0,downloadLocation:https://example.com/artifacts/python-ref1.tgz,digest:sha256=aa1111111111111111111111111111111111111111111111111111111111111111,mediaType:application/octet-stream", + Timestamp: tm, + Justification: "Retrieved from reference predicate", + Origin: "GUAC Reference Certifier", + Collector: "GUAC", + }, + }, + { + Pkg: &generated.PkgInputSpec{ + Type: "pypi", + Name: "example-python", + Version: ptrfrom.String("3.9.0"), + Namespace: ptrfrom.String(""), + Subpath: ptrfrom.String(""), + }, + PkgMatchFlag: generated.MatchFlags{Pkg: generated.PkgMatchTypeSpecificVersion}, + HasMetadata: &generated.HasMetadataInputSpec{ + Key: "reference", + Value: "attester:attester-xyz,ref#1,downloadLocation:https://example.com/artifacts/python-ref2.whl,digest:sha256=bb2222222222222222222222222222222222222222222222222222222222222222,mediaType:application/zip", + Timestamp: tm, + Justification: "Retrieved from reference predicate", + Origin: "GUAC Reference Certifier", + Collector: "GUAC", + }, + }, + }, + wantErr: false, + }, + { + name: "no subject found", + doc: &processor.Document{ + Blob: testdata.ITE6ReferenceNoSubject, + Format: processor.FormatJSON, + Type: processor.DocumentITE6Reference, + SourceInformation: processor.SourceInformation{ + Collector: "TestCollector", + Source: "TestSource", + }, + }, + wantHasMet: nil, + wantErr: true, + }, + } + + var ignoreHMTimestamp = cmp.FilterPath(func(p cmp.Path) bool { + return strings.Compare(".Timestamp", p[len(p)-1].String()) == 0 + }, cmp.Ignore()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewReferenceParser() + err := p.Parse(ctx, tt.doc) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + ip := p.GetPredicates(ctx) + if diff := cmp.Diff(tt.wantHasMet, ip.HasMetadata, ignoreHMTimestamp); diff != "" { + t.Errorf("unexpected results. (-want +got):\n%s", diff) + } + }) + } +}