diff --git a/internal/validate/vsa/rekor_retriever.go b/internal/validate/vsa/rekor_retriever.go new file mode 100644 index 000000000..d66f476d0 --- /dev/null +++ b/internal/validate/vsa/rekor_retriever.go @@ -0,0 +1,342 @@ +// Copyright The Conforma Contributors +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package vsa + +import ( + "context" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/sigstore/cosign/v2/cmd/cosign/cli/rekor" + "github.com/sigstore/rekor/pkg/generated/client" + "github.com/sigstore/rekor/pkg/generated/client/entries" + "github.com/sigstore/rekor/pkg/generated/client/index" + "github.com/sigstore/rekor/pkg/generated/models" + log "github.com/sirupsen/logrus" +) + +// RekorVSARetriever implements VSARetriever using Rekor API +type RekorVSARetriever struct { + client RekorClient + options RetrievalOptions +} + +// RekorClient defines the interface for Rekor client operations +// This allows for easy mocking in tests +type RekorClient interface { + SearchIndex(ctx context.Context, query *models.SearchIndex) ([]models.LogEntryAnon, error) + SearchLogQuery(ctx context.Context, query *models.SearchLogQuery) ([]models.LogEntryAnon, error) + GetLogEntryByIndex(ctx context.Context, index int64) (*models.LogEntryAnon, error) + GetLogEntryByUUID(ctx context.Context, uuid string) (*models.LogEntryAnon, error) +} + +// NewRekorVSARetriever creates a new Rekor-based VSA retriever +func NewRekorVSARetriever(opts RetrievalOptions) (*RekorVSARetriever, error) { + if opts.URL == "" { + return nil, fmt.Errorf("RekorURL is required") + } + + client, err := rekor.NewClient(opts.URL) + if err != nil { + return nil, fmt.Errorf("failed to create Rekor client: %w", err) + } + + return &RekorVSARetriever{ + client: &rekorClient{client: client}, + options: opts, + }, nil +} + +// NewRekorVSARetrieverWithClient creates a new Rekor-based VSA retriever with a custom client +// This is primarily for testing purposes +func NewRekorVSARetrieverWithClient(client RekorClient, opts RetrievalOptions) *RekorVSARetriever { + log.Debugf("Creating RekorVSARetriever with custom client") + return &RekorVSARetriever{ + client: client, + options: opts, + } +} + +// RetrieveVSA implements VSARetriever.RetrieveVSA +func (r *RekorVSARetriever) RetrieveVSA(ctx context.Context, imageDigest string) ([]VSARecord, error) { + if imageDigest == "" { + return nil, fmt.Errorf("image digest cannot be empty") + } + + // Validate image digest format + if !isValidImageDigest(imageDigest) { + return nil, fmt.Errorf("invalid image digest format: %s", imageDigest) + } + + // Create context with timeout if specified + if r.options.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.options.Timeout) + defer cancel() + } + + log.Debugf("Retrieving VSA records for image digest: %s", imageDigest) + + // Search for entries containing the image digest + entries, err := r.searchForImageDigest(ctx, imageDigest) + if err != nil { + return nil, fmt.Errorf("failed to search Rekor for image digest: %w", err) + } + + log.Debugf("RetrieveVSA: search returned %d entries", len(entries)) + + var vsaRecords []VSARecord + + // Process each entry to find VSA records + for _, entry := range entries { + log.Debugf("Processing entry: LogIndex=%v, LogID=%v", entry.LogIndex, entry.LogID) + if isVSARecord(entry, imageDigest) { + vsaRecord, err := r.parseVSARecord(entry) + if err != nil { + log.Warnf("Failed to parse VSA record: %v", err) + continue + } + vsaRecords = append(vsaRecords, vsaRecord) + log.Debugf("Added VSA record: LogIndex=%d, LogID=%s", vsaRecord.LogIndex, vsaRecord.LogID) + } else { + log.Debugf("Entry is not a VSA record") + } + } + + log.Debugf("Found %d VSA records for image digest: %s", len(vsaRecords), imageDigest) + return vsaRecords, nil +} + +// searchForImageDigest searches Rekor for entries containing the given image digest +func (r *RekorVSARetriever) searchForImageDigest(ctx context.Context, imageDigest string) ([]models.LogEntryAnon, error) { + log.Debugf("searchForImageDigest called with imageDigest: %s", imageDigest) + + // Create search query using the search index API + query := &models.SearchIndex{ + Hash: imageDigest, + } + + log.Debugf("Calling client.SearchIndex") + entries, err := r.client.SearchIndex(ctx, query) + if err != nil { + log.Debugf("SearchIndex returned error: %v", err) + return nil, fmt.Errorf("failed to search Rekor index: %w", err) + } + + log.Debugf("Search returned %d entries", len(entries)) + + // The search index should return only entries containing our image digest + // No need for additional filtering + return entries, nil +} + +// isValidImageDigest validates the format of an image digest +func isValidImageDigest(digest string) bool { + // Image digest should be in format: algorithm:hash + parts := strings.Split(digest, ":") + if len(parts) != 2 { + return false + } + + // Check if algorithm is supported (sha256, sha512, etc.) + algorithm := parts[0] + if algorithm != "sha256" && algorithm != "sha512" { + return false + } + + // Check if hash is valid hex + hash := parts[1] + if len(hash) == 0 { + return false + } + + // Validate hex format + _, err := hex.DecodeString(hash) + return err == nil +} + +// isVSARecord determines if a Rekor entry contains a VSA record for the given image digest +func isVSARecord(entry models.LogEntryAnon, imageDigest string) bool { + // Check if entry has attestation data + if entry.Attestation == nil || entry.Attestation.Data == nil { + log.Debugf("Entry has no attestation data") + return false + } + + // Decode the attestation data to check for VSA predicate type + attestationData, err := base64.StdEncoding.DecodeString(string(entry.Attestation.Data)) + if err != nil { + log.Debugf("Failed to decode attestation data: %v", err) + return false + } + + // Check if the attestation contains the VSA predicate type + attestationStr := string(attestationData) + vsaPredicateType := "https://conforma.dev/verification_summary/v1" + + if strings.Contains(attestationStr, vsaPredicateType) { + log.Debugf("Found VSA predicate type in attestation") + return true + } + + log.Debugf("Attestation does not contain VSA predicate type") + return false +} + +// parseVSARecord converts a Rekor log entry to a VSARecord +func (r *RekorVSARetriever) parseVSARecord(entry models.LogEntryAnon) (VSARecord, error) { + record := VSARecord{ + Attestation: entry.Attestation, + Verification: entry.Verification, + } + + // Extract log index + if entry.LogIndex != nil { + record.LogIndex = *entry.LogIndex + } + + // Extract log ID + if entry.LogID != nil { + record.LogID = *entry.LogID + } + + // Extract integrated time + if entry.IntegratedTime != nil { + record.IntegratedTime = *entry.IntegratedTime + } + + // Extract body + if entry.Body != nil { + if bodyStr, ok := entry.Body.(string); ok { + record.Body = bodyStr + } + } + + return record, nil +} + +// rekorClient wraps the actual Rekor client to implement our interface +type rekorClient struct { + client *client.Rekor +} + +func (rc *rekorClient) SearchIndex(ctx context.Context, query *models.SearchIndex) ([]models.LogEntryAnon, error) { + params := &index.SearchIndexParams{ + Context: ctx, + Query: query, + } + + result, err := rc.client.Index.SearchIndex(params) + if err != nil { + return nil, err + } + + // SearchIndex returns a list of UUIDs, we need to fetch the full entries + var entries []models.LogEntryAnon + + if result.Payload != nil { + for _, uuid := range result.Payload { + // Fetch the full log entry for each UUID + entry, err := rc.GetLogEntryByUUID(ctx, uuid) + if err != nil { + log.Debugf("Failed to fetch log entry for UUID %s: %v", uuid, err) + continue + } + if entry != nil { + entries = append(entries, *entry) + } + } + } + + log.Debugf("SearchIndex returned %d UUIDs, fetched %d full entries", len(result.Payload), len(entries)) + return entries, nil +} + +func (rc *rekorClient) SearchLogQuery(ctx context.Context, query *models.SearchLogQuery) ([]models.LogEntryAnon, error) { + params := &entries.SearchLogQueryParams{ + Context: ctx, + Entry: query, + } + + result, err := rc.client.Entries.SearchLogQuery(params) + if err != nil { + return nil, err + } + + var entries []models.LogEntryAnon + if result.Payload != nil { + for _, logEntryMap := range result.Payload { + // Each logEntryMap is a models.LogEntry (map[string]models.LogEntryAnon) + // Extract all LogEntryAnon values from the map + for _, entry := range logEntryMap { + entries = append(entries, entry) + } + } + } + + return entries, nil +} + +func (rc *rekorClient) GetLogEntryByIndex(ctx context.Context, index int64) (*models.LogEntryAnon, error) { + params := &entries.GetLogEntryByIndexParams{ + Context: ctx, + LogIndex: index, + } + + result, err := rc.client.Entries.GetLogEntryByIndex(params) + if err != nil { + return nil, err + } + + // Convert the result to the expected format + // GetLogEntryByIndex returns a map[string]models.LogEntryAnon + if result.Payload != nil { + // The payload is a map where the key is the UUID and value is the log entry + // We need to find the entry by index, but the map is keyed by UUID + // For now, return the first entry found (this might need refinement) + for _, entry := range result.Payload { + return &entry, nil + } + } + + return nil, fmt.Errorf("log entry not found for index: %d", index) +} + +func (rc *rekorClient) GetLogEntryByUUID(ctx context.Context, uuid string) (*models.LogEntryAnon, error) { + params := &entries.GetLogEntryByUUIDParams{ + Context: ctx, + EntryUUID: uuid, + } + + result, err := rc.client.Entries.GetLogEntryByUUID(params) + if err != nil { + return nil, err + } + + // Convert the result to the expected format + // GetLogEntryByUUID returns a map[string]models.LogEntryAnon + if result.Payload != nil { + // The payload is a map where the key is the UUID and value is the log entry + if entry, exists := result.Payload[uuid]; exists { + return &entry, nil + } + } + + return nil, fmt.Errorf("log entry not found for UUID: %s", uuid) +} diff --git a/internal/validate/vsa/retrieval.go b/internal/validate/vsa/retrieval.go new file mode 100644 index 000000000..71b2506e5 --- /dev/null +++ b/internal/validate/vsa/retrieval.go @@ -0,0 +1,54 @@ +// Copyright The Conforma Contributors +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package vsa + +import ( + "context" + "time" + + "github.com/sigstore/rekor/pkg/generated/models" +) + +// VSARetriever defines the interface for retrieving VSA records from Rekor +type VSARetriever interface { + // RetrieveVSA retrieves VSA records for a given image digest + RetrieveVSA(ctx context.Context, imageDigest string) ([]VSARecord, error) +} + +// VSARecord represents a VSA record retrieved from Rekor +type VSARecord struct { + LogIndex int64 `json:"logIndex"` + LogID string `json:"logID"` + IntegratedTime int64 `json:"integratedTime"` + UUID string `json:"uuid"` + Body string `json:"body"` + Attestation *models.LogEntryAnonAttestation `json:"attestation,omitempty"` + Verification *models.LogEntryAnonVerification `json:"verification,omitempty"` +} + +// RetrievalOptions configures VSA retrieval behavior +type RetrievalOptions struct { + URL string + Timeout time.Duration +} + +// DefaultRetrievalOptions returns default options for VSA retrieval +func DefaultRetrievalOptions() RetrievalOptions { + return RetrievalOptions{ + Timeout: 30 * time.Second, + } +} diff --git a/internal/validate/vsa/retrieval_test.go b/internal/validate/vsa/retrieval_test.go new file mode 100644 index 000000000..0e7df0f8e --- /dev/null +++ b/internal/validate/vsa/retrieval_test.go @@ -0,0 +1,482 @@ +// Copyright The Conforma Contributors +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package vsa + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "testing" + "time" + + "github.com/go-openapi/strfmt" + "github.com/sigstore/rekor/pkg/generated/models" + "github.com/stretchr/testify/assert" +) + +// mockRekorClient implements RekorClient for testing +type mockRekorClient struct { + searchEntries []models.LogEntryAnon + searchError error + indexEntry *models.LogEntryAnon + indexError error + uuidEntry *models.LogEntryAnon + uuidError error +} + +func (m *mockRekorClient) SearchIndex(ctx context.Context, query *models.SearchIndex) ([]models.LogEntryAnon, error) { + if m.searchError != nil { + return nil, m.searchError + } + fmt.Printf("Mock SearchIndex called, returning %d entries\n", len(m.searchEntries)) + return m.searchEntries, nil +} + +func (m *mockRekorClient) SearchLogQuery(ctx context.Context, query *models.SearchLogQuery) ([]models.LogEntryAnon, error) { + if m.searchError != nil { + return nil, m.searchError + } + fmt.Printf("Mock SearchLogQuery called, returning %d entries\n", len(m.searchEntries)) + return m.searchEntries, nil +} + +func (m *mockRekorClient) GetLogEntryByIndex(ctx context.Context, index int64) (*models.LogEntryAnon, error) { + if m.indexError != nil { + return nil, m.indexError + } + return m.indexEntry, nil +} + +func (m *mockRekorClient) GetLogEntryByUUID(ctx context.Context, uuid string) (*models.LogEntryAnon, error) { + if m.uuidError != nil { + return nil, m.uuidError + } + return m.uuidEntry, nil +} + +func TestNewRekorVSARetriever(t *testing.T) { + tests := []struct { + name string + options RetrievalOptions + expectError bool + errorMsg string + }{ + { + name: "valid options", + options: RetrievalOptions{ + URL: "https://rekor.example.com", + Timeout: 30 * time.Second, + }, + expectError: false, + }, + { + name: "missing rekor URL", + options: RetrievalOptions{ + Timeout: 30 * time.Second, + }, + expectError: true, + errorMsg: "RekorURL is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retriever, err := NewRekorVSARetriever(tt.options) + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + assert.Nil(t, retriever) + } else { + assert.NoError(t, err) + assert.NotNil(t, retriever) + assert.Equal(t, tt.options, retriever.options) + } + }) + } +} + +func TestRekorVSARetriever_RetrieveVSA(t *testing.T) { + tests := []struct { + name string + imageDigest string + mockEntries []models.LogEntryAnon + mockError error + expectedCount int + expectError bool + expectedError string + }{ + { + name: "single VSA record found", + imageDigest: "sha256:abc123", + mockEntries: []models.LogEntryAnon{ + { + LogIndex: int64Ptr(1), + LogID: stringPtr("test-log-id"), + IntegratedTime: int64Ptr(1234567890), + Body: "test-body", + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("eyJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9jb25mb3JtYS5kZXYvdmVyaWZpY2F0aW9uX3N1bW1hcnkvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicXVheS5pby90ZXN0L2ltYWdlIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFiYzEyMyJ9fV19"), + }, + }, + }, + expectedCount: 1, + expectError: false, + }, + { + name: "multiple VSA records found", + imageDigest: "sha256:abc123", + mockEntries: []models.LogEntryAnon{ + { + LogIndex: int64Ptr(1), + LogID: stringPtr("test-log-id-1"), + IntegratedTime: int64Ptr(1234567890), + Body: "test-body-1", + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("eyJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9jb25mb3JtYS5kZXYvdmVyaWZpY2F0aW9uX3N1bW1hcnkvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicXVheS5pby90ZXN0L2ltYWdlIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFiYzEyMyJ9fV19"), + }, + }, + { + LogIndex: int64Ptr(2), + LogID: stringPtr("test-log-id-2"), + IntegratedTime: int64Ptr(1234567891), + Body: "test-body-2", + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("eyJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9jb25mb3JtYS5kZXYvdmVyaWZpY2F0aW9uX3N1bW1hcnkvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicXVheS5pby90ZXN0L2ltYWdlIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFiYzEyMyJ9fV19"), + }, + }, + }, + expectedCount: 2, + expectError: false, + }, + { + name: "no VSA records found", + imageDigest: "sha256:abc123", + mockEntries: []models.LogEntryAnon{}, + expectedCount: 0, + expectError: false, + }, + { + name: "empty image digest", + imageDigest: "", + expectError: true, + expectedError: "image digest cannot be empty", + }, + { + name: "invalid image digest format", + imageDigest: "invalid-digest", + expectError: true, + expectedError: "invalid image digest format", + }, + { + name: "Rekor search error", + imageDigest: "sha256:abc123", + mockError: errors.New("rekor unreachable"), + expectError: true, + expectedError: "failed to search Rekor for image digest", + }, + { + name: "unsupported algorithm", + imageDigest: "md5:abc123", + expectError: true, + expectedError: "invalid image digest format", + }, + { + name: "invalid hex hash", + imageDigest: "sha256:invalid-hex", + expectError: true, + expectedError: "invalid image digest format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockRekorClient{ + searchEntries: tt.mockEntries, + searchError: tt.mockError, + } + + t.Logf("Creating retriever with mock client") + retriever := NewRekorVSARetrieverWithClient(mockClient, RetrievalOptions{ + URL: "https://rekor.example.com", + Timeout: 30 * time.Second, + }) + + // Debug: Check what the mock client has + t.Logf("Mock client has %d entries", len(tt.mockEntries)) + for i, entry := range tt.mockEntries { + t.Logf("Entry %d: LogIndex=%v, LogID=%v, Body=%v, Attestation=%v", + i, entry.LogIndex, entry.LogID, entry.Body, entry.Attestation) + if entry.Attestation != nil && entry.Attestation.Data != nil { + decoded, err := base64.StdEncoding.DecodeString(string(entry.Attestation.Data)) + t.Logf("Entry %d: Decoded attestation data: %s (err: %v)", i, string(decoded), err) + } + } + + t.Logf("Calling RetrieveVSA with image digest: %s", tt.imageDigest) + records, err := retriever.RetrieveVSA(context.Background(), tt.imageDigest) + + if tt.expectError { + assert.Error(t, err) + if tt.expectedError != "" { + assert.Contains(t, err.Error(), tt.expectedError) + } + assert.Nil(t, records) + } else { + assert.NoError(t, err) + t.Logf("Retrieved %d records", len(records)) + assert.Len(t, records, tt.expectedCount) + + // Verify record structure + for i, record := range records { + assert.NotZero(t, record.LogIndex) + assert.NotEmpty(t, record.LogID) + assert.NotZero(t, record.IntegratedTime) + assert.NotNil(t, record.Attestation) + + // Verify the record contains the image digest + assert.True(t, isVSARecord(tt.mockEntries[i], tt.imageDigest)) + } + } + }) + } +} + +func TestIsValidImageDigest(t *testing.T) { + tests := []struct { + name string + digest string + expected bool + }{ + { + name: "valid sha256 digest", + digest: "sha256:abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890", + expected: true, + }, + { + name: "valid sha512 digest", + digest: "sha512:abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890", + expected: true, + }, + { + name: "empty digest", + digest: "", + expected: false, + }, + { + name: "missing algorithm", + digest: "abc123", + expected: false, + }, + { + name: "unsupported algorithm", + digest: "md5:abc123", + expected: false, + }, + { + name: "invalid hex hash", + digest: "sha256:invalid-hex", + expected: false, + }, + { + name: "empty hash", + digest: "sha256:", + expected: false, + }, + { + name: "multiple colons", + digest: "sha256:abc:123", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidImageDigest(tt.digest) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsVSARecord(t *testing.T) { + tests := []struct { + name string + entry models.LogEntryAnon + imageDigest string + expected bool + }{ + { + name: "valid VSA record with predicate type", + entry: models.LogEntryAnon{ + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("eyJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9jb25mb3JtYS5kZXYvdmVyaWZpY2F0aW9uX3N1bW1hcnkvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicXVheS5pby90ZXN0L2ltYWdlIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFiYzEyMyJ9fV19"), + }, + }, + imageDigest: "sha256:abc123", + expected: true, + }, + { + name: "entry without attestation", + entry: models.LogEntryAnon{ + Body: "sha256:abc123", + }, + imageDigest: "sha256:abc123", + expected: false, + }, + { + name: "entry with nil attestation data", + entry: models.LogEntryAnon{ + Attestation: &models.LogEntryAnonAttestation{ + Data: nil, + }, + }, + imageDigest: "sha256:abc123", + expected: false, + }, + { + name: "entry with non-VSA attestation", + entry: models.LogEntryAnon{ + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("eyJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9vdGhlci1hdHRlc3RhdGlvbiIsInN1YmplY3QiOlt7Im5hbWUiOiJxdWF5LmlvL3Rlc3QvaW1hZ2UiLCJkaWdlc3QiOnsic2hhMjU2IjoiZGVmNDU2In19XX0="), + }, + }, + imageDigest: "sha256:abc123", + expected: false, + }, + { + name: "entry with malformed attestation data", + entry: models.LogEntryAnon{ + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("aW52YWxpZC1iYXNlNjQ="), + }, + }, + imageDigest: "sha256:abc123", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVSARecord(tt.entry, tt.imageDigest) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseVSARecord(t *testing.T) { + retriever := &RekorVSARetriever{} + + tests := []struct { + name string + entry models.LogEntryAnon + expected VSARecord + expectError bool + }{ + { + name: "complete entry", + entry: models.LogEntryAnon{ + LogIndex: int64Ptr(1), + LogID: stringPtr("test-log-id"), + IntegratedTime: int64Ptr(1234567890), + Body: "test-body", + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("dGVzdC1kYXRh"), + }, + Verification: &models.LogEntryAnonVerification{ + InclusionProof: &models.InclusionProof{}, + }, + }, + expected: VSARecord{ + LogIndex: 1, + LogID: "test-log-id", + IntegratedTime: 1234567890, + Body: "test-body", + Attestation: &models.LogEntryAnonAttestation{ + Data: strfmt.Base64("dGVzdC1kYXRh"), + }, + Verification: &models.LogEntryAnonVerification{ + InclusionProof: &models.InclusionProof{}, + }, + }, + expectError: false, + }, + { + name: "entry with nil fields", + entry: models.LogEntryAnon{ + LogIndex: nil, + LogID: nil, + IntegratedTime: nil, + Body: nil, + }, + expected: VSARecord{ + LogIndex: 0, + LogID: "", + IntegratedTime: 0, + Body: "", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := retriever.parseVSARecord(tt.entry) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestDefaultRetrievalOptions(t *testing.T) { + opts := DefaultRetrievalOptions() + + assert.Equal(t, 30*time.Second, opts.Timeout) + assert.Empty(t, opts.URL) +} + +func TestMockRekorClient(t *testing.T) { + mockClient := &mockRekorClient{ + searchEntries: []models.LogEntryAnon{ + { + LogIndex: int64Ptr(1), + LogID: stringPtr("test-log-id"), + }, + }, + } + + entries, err := mockClient.SearchLogQuery(context.Background(), &models.SearchLogQuery{}) + assert.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, int64(1), *entries[0].LogIndex) + assert.Equal(t, "test-log-id", *entries[0].LogID) +} + +// Helper functions for creating test data +func int64Ptr(v int64) *int64 { + return &v +} + +func stringPtr(v string) *string { + return &v +}