diff --git a/acceptance/cli/cli.go b/acceptance/cli/cli.go index 01e1a22e6..c9419348d 100644 --- a/acceptance/cli/cli.go +++ b/acceptance/cli/cli.go @@ -366,6 +366,10 @@ func setupGitHost(ctx context.Context, vars map[string]string, environment []str environment = append(environment, fmt.Sprintf("SSL_CERT_FILE=%s", git.CertificatePath(ctx)), "GIT_SSL_NO_VERIFY=true") vars["GITHOST"] = git.Host(ctx) + latestCommit := git.LatestCommit(ctx) + if latestCommit != "" { + vars["LATEST_COMMIT"] = latestCommit + } return environment, vars, nil } diff --git a/acceptance/git/git.go b/acceptance/git/git.go index 671403241..5c2ac06ed 100644 --- a/acceptance/git/git.go +++ b/acceptance/git/git.go @@ -57,6 +57,7 @@ type gitState struct { HostAndPort string RepositoriesDir string CertificatePath string + LatestCommit string } func (g gitState) Key() any { @@ -186,6 +187,10 @@ func Host(ctx context.Context) string { return testenv.FetchState[gitState](ctx).HostAndPort } +func LatestCommit(ctx context.Context) string { + return testenv.FetchState[gitState](ctx).LatestCommit +} + // CertificatePath returns the path to the self-signed certificate used for TLS // handshake func CertificatePath(ctx context.Context) string { @@ -270,13 +275,15 @@ func createGitRepository(ctx context.Context, repositoryName string, files *godo } // do a `git commit` - _, err = w.Commit("test data", &git.CommitOptions{ + h, err := w.Commit("test data", &git.CommitOptions{ Author: &object.Signature{ Name: "Testy McTestface", Email: "test@test.test", When: time.Date(1970, time.January, 1, 0, 9, 9, 9, time.UTC), // makes commits deterministic }, }) + + state.LatestCommit = h.String() if err != nil { return err } diff --git a/cmd/inspect/inspect_policy_test.go b/cmd/inspect/inspect_policy_test.go index 930a09b50..bfb559802 100644 --- a/cmd/inspect/inspect_policy_test.go +++ b/cmd/inspect/inspect_policy_test.go @@ -24,6 +24,7 @@ import ( "testing" "github.com/enterprise-contract/go-gather/metadata" + fileMetadata "github.com/enterprise-contract/go-gather/metadata/file" "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -42,7 +43,7 @@ type mockDownloader struct { func (m *mockDownloader) Download(_ context.Context, dest string, sourceUrl string, showMsg bool) (metadata.Metadata, error) { args := m.Called(dest, sourceUrl, showMsg) - return nil, args.Error(0) + return args.Get(0).(metadata.Metadata), args.Error(1) } func TestFetchSourcesFromPolicy(t *testing.T) { @@ -63,9 +64,9 @@ func TestFetchSourcesFromPolicy(t *testing.T) { } } - downloader.On("Download", mock.Anything, "one", false).Return(nil).Run(createDir) - downloader.On("Download", mock.Anything, "two", false).Return(nil).Run(createDir) - downloader.On("Download", mock.Anything, "three", false).Return(nil).Run(createDir) + downloader.On("Download", mock.Anything, "one", false).Return(&fileMetadata.FileMetadata{}, nil).Run(createDir) + downloader.On("Download", mock.Anything, "two", false).Return(&fileMetadata.FileMetadata{}, nil).Run(createDir) + downloader.On("Download", mock.Anything, "three", false).Return(&fileMetadata.FileMetadata{}, nil).Run(createDir) inspectPolicyCmd := inspectPolicyCmd() cmd := setUpCobra(inspectPolicyCmd) @@ -105,9 +106,9 @@ func TestFetchSources(t *testing.T) { } } - downloader.On("Download", mock.Anything, "one", false).Return(nil).Run(createDir) - downloader.On("Download", mock.Anything, "two", false).Return(nil).Run(createDir) - downloader.On("Download", mock.Anything, "three", false).Return(nil).Run(createDir) + downloader.On("Download", mock.Anything, "one", false).Return(&fileMetadata.FileMetadata{}, nil).Run(createDir) + downloader.On("Download", mock.Anything, "two", false).Return(&fileMetadata.FileMetadata{}, nil).Run(createDir) + downloader.On("Download", mock.Anything, "three", false).Return(&fileMetadata.FileMetadata{}, nil).Run(createDir) inspectPolicyCmd := inspectPolicyCmd() cmd := setUpCobra(inspectPolicyCmd) diff --git a/cmd/validate/__snapshots__/image_test.snap b/cmd/validate/__snapshots__/image_test.snap index 69592c8a9..4f7107032 100755 --- a/cmd/validate/__snapshots__/image_test.snap +++ b/cmd/validate/__snapshots__/image_test.snap @@ -28,7 +28,7 @@ ] }, "policy": [ - "quay.io/hacbs-contract/ec-release-policy:latest" + "oci://quay.io/hacbs-contract/ec-release-policy:latest@sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357" ] } ] @@ -66,7 +66,7 @@ ] }, "policy": [ - "quay.io/hacbs-contract/ec-release-policy:latest" + "oci://quay.io/hacbs-contract/ec-release-policy:latest@sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357" ] } ] diff --git a/cmd/validate/common_test.go b/cmd/validate/common_test.go index 322c64a57..340904fb8 100644 --- a/cmd/validate/common_test.go +++ b/cmd/validate/common_test.go @@ -21,6 +21,7 @@ package validate import ( "context" + "github.com/enterprise-contract/go-gather/metadata" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/spf13/cobra" @@ -56,6 +57,16 @@ func (e *mockEvaluator) CapabilitiesPath() string { return args.String(0) } +type MockDownloader struct { + mock.Mock +} + +func (m *MockDownloader) Download(_ context.Context, dest string, sourceUrl string, showMsg bool) (metadata.Metadata, error) { + args := m.Called(dest, sourceUrl, showMsg) + + return args.Get(0).(metadata.Metadata), args.Error(1) +} + func setUpCobra(command *cobra.Command) *cobra.Command { validateCmd := NewValidateCmd() validateCmd.AddCommand(command) diff --git a/cmd/validate/image.go b/cmd/validate/image.go index c1e421aba..eb3e930cb 100644 --- a/cmd/validate/image.go +++ b/cmd/validate/image.go @@ -208,7 +208,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { } data.policyConfiguration = policyConfiguration - if p, err := policy.NewPolicy(cmd.Context(), policy.Options{ + policyOptions := policy.Options{ EffectiveTime: data.effectiveTime, Identity: cosign.Identity{ Issuer: data.certificateOIDCIssuer, @@ -220,7 +220,11 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { PolicyRef: data.policyConfiguration, PublicKey: data.publicKey, RekorURL: data.rekorURL, - }); err != nil { + } + + // We're not currently using the policyCache returned from PreProcessPolicy, but we could + // use it to cache the policy for future use. + if p, _, err := policy.PreProcessPolicy(ctx, policyOptions); err != nil { allErrors = errors.Join(allErrors, err) } else { // inject extra variables into rule data per source @@ -322,8 +326,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { log.Debugf("Starting worker %d", id) for comp := range jobs { log.Debugf("Worker %d got a component %q", id, comp.ContainerImage) - ctx := cmd.Context() - out, err := validate(ctx, comp, data.spec, data.policy, evaluators, data.info) + out, err := validate(cmd.Context(), comp, data.spec, data.policy, evaluators, data.info) res := result{ err: err, component: applicationsnapshot.Component{ diff --git a/cmd/validate/image_integration_test.go b/cmd/validate/image_integration_test.go index 9c211bdcb..d94faddaf 100644 --- a/cmd/validate/image_integration_test.go +++ b/cmd/validate/image_integration_test.go @@ -28,6 +28,7 @@ import ( "time" "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" + ociMetadata "github.com/enterprise-contract/go-gather/metadata/oci" app "github.com/konflux-ci/application-api/api/v1alpha1" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -44,15 +45,19 @@ import ( ) func TestEvaluatorLifecycle(t *testing.T) { + noEvaluators := 100 + ctx := utils.WithFS(context.Background(), afero.NewMemMapFs()) client := fake.FakeClient{} commonMockClient(&client) ctx = oci.WithClient(ctx, &client) - - noEvaluators := 100 + mdl := MockDownloader{} + downloaderCall := mdl.On("Download", mock.Anything, mock.Anything, false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil).Times(noEvaluators) + ctx = context.WithValue(ctx, source.DownloaderFuncKey, &mdl) evaluators := make([]*mockEvaluator, 0, noEvaluators) - expectations := make([]*mock.Call, 0, noEvaluators) + expectations := make([]*mock.Call, 0, noEvaluators+1) + expectations = append(expectations, downloaderCall) for i := 0; i < noEvaluators; i++ { e := mockEvaluator{} @@ -67,7 +72,8 @@ func TestEvaluatorLifecycle(t *testing.T) { } newConftestEvaluator = func(_ context.Context, s []source.PolicySource, _ evaluator.ConfigProvider, _ v1alpha1.Source) (evaluator.Evaluator, error) { - idx, err := strconv.Atoi(s[0].PolicyUrl()) + // We are splitting this url to get to the index of the evaluator. + idx, err := strconv.Atoi(strings.Split(strings.Split(s[0].PolicyUrl(), "@")[0], "://")[1]) require.NoError(t, err) return evaluators[idx], nil diff --git a/cmd/validate/image_test.go b/cmd/validate/image_test.go index d3982bb3a..e9f83e207 100644 --- a/cmd/validate/image_test.go +++ b/cmd/validate/image_test.go @@ -29,6 +29,7 @@ import ( "time" hd "github.com/MakeNowJust/heredoc" + ociMetadata "github.com/enterprise-contract/go-gather/metadata/oci" "github.com/gkampitakis/go-snaps/snaps" app "github.com/konflux-ci/application-api/api/v1alpha1" "github.com/sigstore/cosign/v2/pkg/cosign" @@ -36,11 +37,13 @@ import ( "github.com/sirupsen/logrus/hooks/test" "github.com/spf13/afero" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/enterprise-contract/ec-cli/internal/applicationsnapshot" "github.com/enterprise-contract/ec-cli/internal/evaluator" "github.com/enterprise-contract/ec-cli/internal/output" "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/policy/source" "github.com/enterprise-contract/ec-cli/internal/utils" "github.com/enterprise-contract/ec-cli/internal/utils/oci" "github.com/enterprise-contract/ec-cli/internal/utils/oci/fake" @@ -528,6 +531,11 @@ spec: fs := afero.NewMemMapFs() ctx := utils.WithFS(context.Background(), fs) ctx = oci.WithClient(ctx, &client) + + mdl := MockDownloader{} + mdl.On("Download", mock.Anything, "quay.io/hacbs-contract/ec-release-policy:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + ctx = context.WithValue(ctx, source.DownloaderFuncKey, &mdl) + cmd.SetContext(ctx) err := afero.WriteFile(fs, "/policy.yaml", []byte(c.config), 0644) @@ -568,6 +576,12 @@ func Test_ValidateImageCommandJSONPolicyFile(t *testing.T) { fs := afero.NewMemMapFs() ctx := utils.WithFS(context.Background(), fs) ctx = oci.WithClient(ctx, &client) + + mdl := MockDownloader{} + mdl.On("Download", mock.Anything, "registry/policy:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + mdl.On("Download", mock.Anything, "registry/policy-data:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + ctx = context.WithValue(ctx, source.DownloaderFuncKey, &mdl) + cmd.SetContext(ctx) testPolicyJSON := `sources: @@ -614,6 +628,11 @@ func Test_ValidateImageCommandExtraData(t *testing.T) { commonMockClient(&client) ctx = oci.WithClient(ctx, &client) + mdl := MockDownloader{} + mdl.On("Download", mock.Anything, "registry/policy:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + mdl.On("Download", mock.Anything, "registry/policy-data:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + ctx = context.WithValue(ctx, source.DownloaderFuncKey, &mdl) + cmd.SetContext(ctx) testPolicyJSON := `sources: @@ -680,10 +699,10 @@ spec: assert.NoError(t, err) assert.JSONEq(t, `{ "data": [ - "registry/policy-data:latest" + "oci://registry/policy-data:latest@sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357" ], "policy": [ - "registry/policy:latest" + "oci://registry/policy:latest@sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357" ], "ruleData": { "custom_rule_data":{"prefix_data":["registry1"]}, @@ -705,6 +724,12 @@ func Test_ValidateImageCommandEmptyPolicyFile(t *testing.T) { fs := afero.NewMemMapFs() ctx := utils.WithFS(context.Background(), fs) ctx = oci.WithClient(ctx, &client) + + mdl := MockDownloader{} + mdl.On("Download", mock.Anything, "registry/policy:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + mdl.On("Download", mock.Anything, "registry/policy-data:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + ctx = context.WithValue(ctx, source.DownloaderFuncKey, &mdl) + cmd.SetContext(ctx) err := afero.WriteFile(fs, "/policy.yaml", []byte(nil), 0644) @@ -747,13 +772,18 @@ func Test_ValidateImageErrorLog(t *testing.T) { commonMockClient(&client) ctx = oci.WithClient(ctx, &client) + mdl := MockDownloader{} + mdl.On("Download", mock.Anything, "oci::registry/policy:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + mdl.On("Download", mock.Anything, "oci::registry/policy-data:latest", false).Return(&ociMetadata.OCIMetadata{Digest: "sha256:da54bca5477bf4e3449bc37de1822888fa0fbb8d89c640218cb31b987374d357"}, nil) + ctx = context.WithValue(ctx, source.DownloaderFuncKey, &mdl) + cmd.SetContext(ctx) testPolicyJSON := `sources: - policy: - - "registry/policy:latest" + - "oci::registry/policy:latest" data: - - "registry/policy-data:latest" + - "oci::registry/policy-data:latest" config: include: - '@minimal' diff --git a/features/__snapshots__/inspect_policy.snap b/features/__snapshots__/inspect_policy.snap index cce9f0a68..9dcbb08e8 100755 --- a/features/__snapshots__/inspect_policy.snap +++ b/features/__snapshots__/inspect_policy.snap @@ -10,7 +10,7 @@ Error: Merge error. The 'rule_data' key was found more than once! [json output:stdout - 1] { - "git::https://${GITHOST}/git/policy.git": [ + "git::https://${GITHOST}/git/policy.git?ref=${LATEST_COMMIT}": [ { "annotations": { "custom": { @@ -57,7 +57,7 @@ Error: Merge error. The 'rule_data' key was found more than once! --- [default output:stdout - 1] -# Source: git::https://${GITHOST}/git/policy.git +# Source: git::https://${GITHOST}/git/policy.git?ref=${LATEST_COMMIT} policy.release.kitty.purr (deny) https://enterprisecontract.dev/docs/ec-policies/release_policy.html#kitty__purr @@ -94,14 +94,14 @@ kitty.purr --- [sources from ECP:stdout - 1] -# Source: git::https://${GITHOST}/git/policy1.git +# Source: git::https://${GITHOST}/git/policy1.git?ref=8288b21ca5e7d8863efffb47c2bc3eac1274d1ff policy.release.kitty.purr (deny) https://enterprisecontract.dev/docs/ec-policies/release_policy.html#kitty__purr Kittens Fluffy -- -# Source: git::https://${GITHOST}/git/policy2.git +# Source: git::https://${GITHOST}/git/policy2.git?ref=${LATEST_COMMIT} main.rejector (deny) Reject rule diff --git a/features/__snapshots__/track_bundle.snap b/features/__snapshots__/track_bundle.snap index f9c6702ce..47390fce6 100755 --- a/features/__snapshots__/track_bundle.snap +++ b/features/__snapshots__/track_bundle.snap @@ -151,7 +151,7 @@ Error: expected "git+https://${GITHOST}/git/tasks.git//task.yaml" to contain the trusted_tasks: git+https://${GITHOST}/git/tasks.git//task.yaml: - effective_on: "${TIMESTAMP}" - ref: 60079661c514e31e542a55aefb8de67bd40597a9 + ref: ${LATEST_COMMIT} --- diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index 4436a0168..dfcaf5077 100755 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -59,7 +59,7 @@ "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=95175b6ea9bb28c645186c6624ff904812ebbca7" ] } ], @@ -161,18 +161,18 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/banana_check.git" + "git::https://${GITHOST}/git/banana_check.git?ref=c6cafb797f5afa8b9c7b1c54ea7bf0ca35368e21" ], "data": [ - "git::https://${GITHOST}/git/banana_data_1.git" + "git::https://${GITHOST}/git/banana_data_1.git?ref=62e50acbb1a230a3f11ca1858fc053b21fe5cc82" ] }, { "policy": [ - "git::https://${GITHOST}/git/banana_check.git" + "git::https://${GITHOST}/git/banana_check.git?ref=c6cafb797f5afa8b9c7b1c54ea7bf0ca35368e21" ], "data": [ - "git::https://${GITHOST}/git/banana_data_2.git" + "git::https://${GITHOST}/git/banana_data_2.git?ref=${LATEST_COMMIT}" ] } ], @@ -250,7 +250,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -327,7 +327,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=95175b6ea9bb28c645186c6624ff904812ebbca7" ] } ], @@ -406,7 +406,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/future-deny-policy.git" + "git::https://${GITHOST}/git/future-deny-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -501,7 +501,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/my-policy1.git" + "git::https://${GITHOST}/git/my-policy1.git?ref=${LATEST_COMMIT}" ], "ruleData": { "custom": "data1" @@ -509,7 +509,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/my-policy2.git" + "git::https://${GITHOST}/git/my-policy2.git?ref=${LATEST_COMMIT}" ], "ruleData": { "other": "data2" @@ -614,17 +614,17 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/repository1.git" + "git::https://${GITHOST}/git/repository1.git?ref=95175b6ea9bb28c645186c6624ff904812ebbca7" ] }, { "policy": [ - "git::https://${GITHOST}/git/repository2.git" + "git::https://${GITHOST}/git/repository2.git?ref=9998384962ba66481defc409b0f821d222ba3366" ] }, { "policy": [ - "git::https://${GITHOST}/git/repository3.git" + "git::https://${GITHOST}/git/repository3.git?ref=${LATEST_COMMIT}" ] } ], @@ -698,7 +698,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/mismatched-image-digest.git" + "git::https://${GITHOST}/git/mismatched-image-digest.git?ref=${LATEST_COMMIT}" ] } ], @@ -782,7 +782,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ], "config": { "exclude": [ @@ -875,7 +875,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ], "config": { "include": [ @@ -971,7 +971,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -1073,9 +1073,9 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/repository1.git", - "git::https://${GITHOST}/git/repository2.git", - "git::https://${GITHOST}/git/repository3.git" + "git::https://${GITHOST}/git/repository1.git?ref=95175b6ea9bb28c645186c6624ff904812ebbca7", + "git::https://${GITHOST}/git/repository2.git?ref=9998384962ba66481defc409b0f821d222ba3366", + "git::https://${GITHOST}/git/repository3.git?ref=${LATEST_COMMIT}" ] } ], @@ -1122,7 +1122,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/unexpected-keyless-cert.git" + "git::https://${GITHOST}/git/unexpected-keyless-cert.git?ref=${LATEST_COMMIT}" ] } ] @@ -1167,7 +1167,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/invalid-image-signature.git" + "git::https://${GITHOST}/git/invalid-image-signature.git?ref=${LATEST_COMMIT}" ] } ], @@ -1245,7 +1245,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ], "config": { "exclude": [ @@ -1276,7 +1276,7 @@ Error: success criteria not met --- [happy day with missing git config:stderr - 1] -Error: no suitable config file found at git::https://${GITHOST}/git/happy-config.git +Error: no suitable config file found at git::https://${GITHOST}/git/happy-config.git?ref=${LATEST_COMMIT} --- @@ -1343,7 +1343,7 @@ Error: no suitable config file found at git::https://${GITHOST}/git/happy-config "sources": [ { "policy": [ - "git::https://${GITHOST}/git/future-deny-policy.git" + "git::https://${GITHOST}/git/future-deny-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -1421,7 +1421,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -1552,7 +1552,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ] @@ -1598,7 +1598,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/mismatched-image-digest.git" + "git::https://${GITHOST}/git/mismatched-image-digest.git?ref=${LATEST_COMMIT}" ] } ], @@ -1676,7 +1676,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -1806,7 +1806,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -1939,7 +1939,7 @@ Results: "sources": [ { "policy": [ - "git::https://${GITHOST}/git/future-deny-policy.git" + "git::https://${GITHOST}/git/future-deny-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -2017,8 +2017,8 @@ Error: success criteria not met "sources": [ { "policy": [ - "oci::https://${REGISTRY}/acceptance/happy-day-policy:tag", - "oci::${REGISTRY}/acceptance/allow-all:latest" + "oci://${REGISTRY}/acceptance/happy-day-policy:tag@sha256:${REGISTRY_acceptance/happy-day-policy:tag_DIGEST}", + "oci://${REGISTRY}/acceptance/allow-all:latest@sha256:${REGISTRY_acceptance/allow-all:latest_DIGEST}" ] } ], @@ -2107,7 +2107,7 @@ ${TEMP}/ec-work-${RANDOM}/policy/${RANDOM}/main.rego:34: rego_type_error: undefi "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -2184,7 +2184,7 @@ ${TEMP}/ec-work-${RANDOM}/policy/${RANDOM}/main.rego:34: rego_type_error: undefi "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "key": "value" @@ -2274,7 +2274,7 @@ ${TEMP}/ec-work-${RANDOM}/policy/${RANDOM}/main.rego:34: rego_type_error: undefi "sources": [ { "policy": [ - "git::https://${GITHOST}/git/with-dependencies.git" + "git::https://${GITHOST}/git/with-dependencies.git?ref=${LATEST_COMMIT}" ] } ], @@ -2366,7 +2366,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/unique-successes.git" + "git::https://${GITHOST}/git/unique-successes.git?ref=${LATEST_COMMIT}" ] } ], @@ -2499,7 +2499,7 @@ ${__________known_PUBLIC_KEY} "sources": [ { "policy": [ - "git::https://${GITHOST}/git/image-config-policy.git" + "git::https://${GITHOST}/git/image-config-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -2550,7 +2550,7 @@ ${__________known_PUBLIC_KEY} "sources": [ { "policy": [ - "git::https://${GITHOST}/git/my-policy.git" + "git::https://${GITHOST}/git/my-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -2699,7 +2699,7 @@ ${__________known_PUBLIC_KEY} "sources": [ { "policy": [ - "git::https://${GITHOST}/git/ignore-rekor.git" + "git::https://${GITHOST}/git/ignore-rekor.git?ref=${LATEST_COMMIT}" ] } ], @@ -2744,7 +2744,7 @@ ${__________known_PUBLIC_KEY} "sources": [ { "policy": [ - "git::https://${GITHOST}/git/rekor-by-default.git" + "git::https://${GITHOST}/git/rekor-by-default.git?ref=${LATEST_COMMIT}" ] } ], @@ -3090,7 +3090,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/olm-manifests.git" + "git::https://${GITHOST}/git/olm-manifests.git?ref=${LATEST_COMMIT}" ] } ], @@ -3177,7 +3177,7 @@ Error: error validating image ${REGISTRY}/acceptance/image of component Unnamed: "sources": [ { "policy": [ - "git::https://${GITHOST}/git/fetch-oci-blob-policy.git" + "git::https://${GITHOST}/git/fetch-oci-blob-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -3254,7 +3254,7 @@ Error: error validating image ${REGISTRY}/acceptance/image of component Unnamed: "sources": [ { "policy": [ - "git::https://${GITHOST}/git/happy-day-policy.git" + "git::https://${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}" ], "config": { "exclude": [ @@ -3361,7 +3361,7 @@ Error: error validating image ${REGISTRY}/acceptance/image of component Unnamed: "sources": [ { "policy": [ - "git::https://${GITHOST}/git/purl-policy.git" + "git::https://${GITHOST}/git/purl-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -3440,7 +3440,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/oci-image-manifest-policy" + "git::https://${GITHOST}/git/oci-image-manifest-policy?ref=${LATEST_COMMIT}" ] } ], @@ -3517,7 +3517,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/sigstore.git" + "git::https://${GITHOST}/git/sigstore.git?ref=${LATEST_COMMIT}" ] } ], @@ -4594,7 +4594,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "key": "value" @@ -4602,7 +4602,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "something": "here" @@ -4610,7 +4610,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "key": "different" @@ -4618,7 +4618,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "hello": "world" @@ -4626,7 +4626,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "foo": "bar" @@ -4634,7 +4634,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "peek": "poke" @@ -4642,7 +4642,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "hide": "seek" @@ -4650,7 +4650,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "hokus": "pokus" @@ -4658,7 +4658,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "mr": "mxyzptlk" @@ -4666,7 +4666,7 @@ Error: success criteria not met }, { "policy": [ - "git::https://${GITHOST}/git/multitude-policy.git" + "git::https://${GITHOST}/git/multitude-policy.git?ref=${LATEST_COMMIT}" ], "ruleData": { "more": "data" @@ -4794,7 +4794,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/my-policy.git" + "git::https://${GITHOST}/git/my-policy.git?ref=${LATEST_COMMIT}" ] } ], @@ -4867,7 +4867,7 @@ Error: success criteria not met "sources": [ { "policy": [ - "git::https://${GITHOST}/git/oci-image-files-policy" + "git::https://${GITHOST}/git/oci-image-files-policy?ref=${LATEST_COMMIT}" ] } ], diff --git a/go.mod b/go.mod index 956e64baa..0bcf37779 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/enterprise-contract/go-gather/metadata v0.0.2 github.com/enterprise-contract/go-gather/metadata/file v0.0.1 github.com/enterprise-contract/go-gather/metadata/git v0.0.2 + github.com/enterprise-contract/go-gather/metadata/http v0.0.1 github.com/enterprise-contract/go-gather/metadata/oci v0.0.3 github.com/evanphx/json-patch v5.9.0+incompatible github.com/gkampitakis/go-snaps v0.5.7 @@ -168,8 +169,7 @@ require ( github.com/enterprise-contract/go-gather v0.0.3 // indirect github.com/enterprise-contract/go-gather/expander v0.0.1 // indirect github.com/enterprise-contract/go-gather/gather/file v0.0.2-0.20240906185922-e8ebd246dc19 // indirect - github.com/enterprise-contract/go-gather/gather/git v0.0.6-0.20240911082231-b67aa65913d1 // indirect - github.com/enterprise-contract/go-gather/metadata/http v0.0.1 // indirect + github.com/enterprise-contract/go-gather/gather/git v0.0.6-0.20240919182827-191282dff6cc // indirect github.com/enterprise-contract/go-gather/saver v0.0.2 // indirect github.com/enterprise-contract/go-gather/saver/file v0.0.1 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect diff --git a/go.sum b/go.sum index b84333c0b..fa2824413 100644 --- a/go.sum +++ b/go.sum @@ -555,8 +555,8 @@ github.com/enterprise-contract/go-gather/gather v0.0.3 h1:rSOLqY+ydsqMtqx4IZ+jQK github.com/enterprise-contract/go-gather/gather v0.0.3/go.mod h1:chij1Nq6vbJP/2sii0sxCuwvrJCJV3hEbH1PpJ/DhwQ= github.com/enterprise-contract/go-gather/gather/file v0.0.2-0.20240906185922-e8ebd246dc19 h1:RJuPPINRrFoo8VQxJgmCeCp+JeH0058vfUOmSvZwj7k= github.com/enterprise-contract/go-gather/gather/file v0.0.2-0.20240906185922-e8ebd246dc19/go.mod h1:xQ3WyIZBpJ00WMo49WWL6kQ0zSj/NZ20lB3rM1sLGoU= -github.com/enterprise-contract/go-gather/gather/git v0.0.6-0.20240911082231-b67aa65913d1 h1:jcNGVIGdjIB5r/bA85ym+QZNkgXQgkYaeSOj1neT3GQ= -github.com/enterprise-contract/go-gather/gather/git v0.0.6-0.20240911082231-b67aa65913d1/go.mod h1:8dLd8gobw9VTlBveuK/smLXdNmNYQKmbLSzNjjTpm64= +github.com/enterprise-contract/go-gather/gather/git v0.0.6-0.20240919182827-191282dff6cc h1:lIdxj8bJbpzc0yxCQm0QPwVYgky9P687ky1thet0ZaM= +github.com/enterprise-contract/go-gather/gather/git v0.0.6-0.20240919182827-191282dff6cc/go.mod h1:8dLd8gobw9VTlBveuK/smLXdNmNYQKmbLSzNjjTpm64= github.com/enterprise-contract/go-gather/gather/http v0.0.3-0.20240923130737-4120ba0d92bf h1:yaNndl55uPdyj7KBizBnrEP/WOO4cxU49OAOTfbhRH0= github.com/enterprise-contract/go-gather/gather/http v0.0.3-0.20240923130737-4120ba0d92bf/go.mod h1:qb/kIRJXDmasYGXCFfNjLa/rkSzUpIdplCAqt8zfllw= github.com/enterprise-contract/go-gather/gather/oci v0.0.5-0.20240923101526-bbc07b341aed h1:hEesi1UVFG8DPhOO7MDSwzABxYqR0S++wPxSmcLu42c= diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index c5728f9ac..66c640a9b 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -294,23 +294,21 @@ func NewConftestEvaluatorWithNamespace(ctx context.Context, policySources []sour } c.include, c.exclude = computeIncludeExclude(source, p) - dir, err := utils.CreateWorkDir(fs) if err != nil { log.Debug("Failed to create work dir!") return nil, err } c.workDir = dir - c.policyDir = filepath.Join(c.workDir, "policy") c.dataDir = filepath.Join(c.workDir, "data") - log.Debugf("Created work dir %s", dir) - if err := c.createDataDirectory(ctx); err != nil { return nil, err } + log.Debugf("Created work dir %s", dir) + if err := c.createCapabilitiesFile(ctx); err != nil { return nil, err } @@ -371,7 +369,6 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget // TODO do we want to download other policies instead of erroring out? return nil, nil, err } - annotations := []*ast.AnnotationsRef{} fs := utils.FS(ctx) // We only want to inspect the directory of policy subdirs, not config or data subdirs. diff --git a/internal/evaluator/conftest_evaluator_test.go b/internal/evaluator/conftest_evaluator_test.go index be061eca7..2665daf60 100644 --- a/internal/evaluator/conftest_evaluator_test.go +++ b/internal/evaluator/conftest_evaluator_test.go @@ -36,6 +36,7 @@ import ( "github.com/MakeNowJust/heredoc" ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" + "github.com/enterprise-contract/go-gather/metadata" "github.com/gkampitakis/go-snaps/snaps" "github.com/open-policy-agent/opa/ast" "github.com/spf13/afero" @@ -71,6 +72,10 @@ func (t testPolicySource) GetPolicy(ctx context.Context, dest string, showMsg bo return "/policy", nil } +func (t testPolicySource) GetPolicyWithMetadata(ctx context.Context, dest string, showMsg bool) (string, metadata.Metadata, error) { + return "/policy", nil, nil +} + func (t testPolicySource) PolicyUrl() string { return "test-url" } @@ -79,6 +84,10 @@ func (t testPolicySource) Subdir() string { return "policy" } +func (testPolicySource) Type() source.PolicyType { + return source.PolicyKind +} + type mockDownloader struct { mock.Mock } diff --git a/internal/policy/cache/cache.go b/internal/policy/cache/cache.go new file mode 100644 index 000000000..f2f4c802f --- /dev/null +++ b/internal/policy/cache/cache.go @@ -0,0 +1,106 @@ +// Copyright The Enterprise Contract 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 cache + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" +) + +// policyCacheKey is the key for PolicyCache values in Context. +// It's unexported to prevent external packages from using it. +var policyCacheKey = policyCacheKeyType{} + +// Define an unexported type to prevent key collisions in context. +type policyCacheKeyType struct{} + +// cacheEntry represents a single cache entry with a value and a sync.Once for initialization. +type cacheEntry struct { + value string + err error +} + +// KeyValuePair represents a key-value pair from the cache. +type KeyValuePair struct { + Key string + Value string +} + +// PolicyCache holds cached policy data using a thread-safe map. +type PolicyCache struct { + Data sync.Map +} + +// Get retrieves the value and error for the given key from the cache. +// It returns the value and true if found, or an empty string and false otherwise. +func (c *PolicyCache) Get(key string) (string, bool) { + actual, ok := c.Data.Load(key) + if !ok { + return "", ok + } + entry, ok := actual.(*cacheEntry) + if !ok { + return "", ok + } + return entry.value, ok +} + +// Set manually sets the value for a given key in the cache. +// It overwrites any existing value and error. +func (c *PolicyCache) Set(key string, value string, err error) { + entry := &cacheEntry{ + value: value, + err: err, + } + c.Data.Store(key, entry) +} + +// NewPolicyCache creates and returns a new PolicyCache instance. +func NewPolicyCache(ctx context.Context) (*PolicyCache, error) { + cache, ok := ctx.Value(policyCacheKey).(*PolicyCache) + if ok && cache != nil { + return cache, nil + } + + c, err := CreatePolicyCache() + if err != nil { + log.Debug("Failed to create PolicyCache") + return nil, err + } + + return c, nil +} + +func CreatePolicyCache() (*PolicyCache, error) { + return &PolicyCache{ + Data: sync.Map{}, + }, nil +} + +// PolicyCacheFromContext retrieves the PolicyCache from the context. +// It returns the PolicyCache and true if found, or nil and false otherwise. +func PolicyCacheFromContext(ctx context.Context) (*PolicyCache, bool) { + cache, ok := ctx.Value(policyCacheKey).(*PolicyCache) + return cache, ok +} + +// WithPolicyCache returns a new context with the provided PolicyCache added. +func WithPolicyCache(ctx context.Context, cache *PolicyCache) context.Context { + return context.WithValue(ctx, policyCacheKey, cache) +} diff --git a/internal/policy/cache/cache_test.go b/internal/policy/cache/cache_test.go new file mode 100644 index 000000000..15f66d348 --- /dev/null +++ b/internal/policy/cache/cache_test.go @@ -0,0 +1,67 @@ +// Copyright The Enterprise Contract 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 cache + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPolicyCache_Get(t *testing.T) { + ctx := context.Background() + cache, err := NewPolicyCache(ctx) + if err != nil { + t.Errorf("Error creating cache: %v", err) + } + + // Test case: Key does not exist + value, ok := cache.Get("nonexistent") + assert.False(t, ok) + assert.Equal(t, "", value) + + // Test case: Key exists + cache.Set("existing", "value", nil) + value, ok = cache.Get("existing") + assert.True(t, ok) + assert.Equal(t, "value", value) + + // Test case: Key exists but with a different type + cache.Data.Store("wrongtype", "string value") + value, ok = cache.Get("wrongtype") + assert.False(t, ok) + assert.Equal(t, "", value) +} +func TestPolicyCacheFromContext(t *testing.T) { + ctx := context.Background() + cache, err := NewPolicyCache(ctx) + if err != nil { + t.Errorf("Error creating cache: %v", err) + } + + // Test case: PolicyCache not in context + retrievedCache, ok := PolicyCacheFromContext(ctx) + assert.False(t, ok) + assert.Nil(t, retrievedCache) + + // Test case: PolicyCache in context + ctxWithCache := WithPolicyCache(ctx, cache) + retrievedCache, ok = PolicyCacheFromContext(ctxWithCache) + assert.True(t, ok) + assert.Equal(t, cache, retrievedCache) +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 93d7a0cb8..064558938 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -27,6 +27,7 @@ import ( "strings" "time" + "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" schemaExporter "github.com/invopop/jsonschema" "github.com/santhosh-tekuri/jsonschema/v5" @@ -40,6 +41,9 @@ import ( "sigs.k8s.io/yaml" "github.com/enterprise-contract/ec-cli/internal/kubernetes" + "github.com/enterprise-contract/ec-cli/internal/policy/cache" + "github.com/enterprise-contract/ec-cli/internal/policy/source" + "github.com/enterprise-contract/ec-cli/internal/utils" ) const ( @@ -51,6 +55,12 @@ const ( // allows controlling time in tests var now = time.Now +var ( + FetchPolicySources = source.FetchPolicySources + CreateWorkDir = utils.CreateWorkDir + PolicyCacheFromContext = cache.PolicyCacheFromContext +) + func ValidatePolicy(ctx context.Context, policyConfig string) error { return validatePolicyConfig(policyConfig) } @@ -571,3 +581,82 @@ func validatePolicyConfig(policyConfig string) error { } return nil } + +// PreProcessPolicy fetches policy sources and returns a policy object with +// pinned SHA/image digest URL where applicable, along with a policy cache object. +func PreProcessPolicy(ctx context.Context, policyOptions Options) (Policy, *cache.PolicyCache, error) { + var policyCache *cache.PolicyCache + pinnedPolicyUrls := map[string][]string{} + policyCache, err := cache.NewPolicyCache(ctx) + if err != nil { + return nil, nil, err + } + + p, err := NewPolicy(ctx, policyOptions) + if err != nil { + return nil, nil, err + } + + sources := p.Spec().Sources + for i, sourceGroup := range sources { + log.Debugf("Fetching policy source group '%+v'\n", sourceGroup.Name) + policySources, err := FetchPolicySources(sourceGroup) + if err != nil { + log.Debugf("Failed to fetch policy source group '%s'!\n", sourceGroup.Name) + return nil, nil, err + } + + fs := utils.FS(ctx) + dir, err := utils.CreateWorkDir(fs) + if err != nil { + log.Debug("Failed to create work dir!") + return nil, nil, err + } + + for _, policySource := range policySources { + if strings.HasPrefix(policySource.PolicyUrl(), "data:") { + continue + } + + destDir, err := policySource.GetPolicy(ctx, dir, false) + if err != nil { + log.Debugf("Unable to download source from %s!", policySource.PolicyUrl()) + return nil, nil, err + } + log.Debugf("Downloaded policy source from %s to %s\n", policySource.PolicyUrl(), destDir) + + url := policySource.PolicyUrl() + + if _, found := policyCache.Get(policySource.PolicyUrl()); !found { + log.Debugf("Cache miss for: %s, adding to cache", url) + policyCache.Set(url, destDir, nil) + pinnedPolicyUrls[policySource.Subdir()] = append(pinnedPolicyUrls[policySource.Subdir()], url) + log.Debugf("Added %s to the pinnedPolicyUrls in \"%s\"", url, policySource.Subdir()) + } else { + log.Debugf("Cache hit for: %s", url) + } + } + + sources[i] = v1alpha1.Source{ + Name: sourceGroup.Name, + Policy: urls(policySources, source.PolicyKind), + Data: urls(policySources, source.DataKind), + RuleData: sourceGroup.RuleData, + Config: sourceGroup.Config, + VolatileConfig: sourceGroup.VolatileConfig, + } + } + + return p, policyCache, err +} + +func urls(s []source.PolicySource, kind source.PolicyType) []string { + ret := make([]string, 0, len(s)) + for _, u := range s { + if u.Type() == kind { + ret = append(ret, u.PolicyUrl()) + } + } + + return ret +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index e758860b2..acb7a456b 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -39,6 +39,7 @@ import ( "sigs.k8s.io/yaml" "github.com/enterprise-contract/ec-cli/internal/kubernetes" + "github.com/enterprise-contract/ec-cli/internal/policy/source" "github.com/enterprise-contract/ec-cli/internal/utils" ) @@ -819,3 +820,45 @@ func TestSigstoreOpts(t *testing.T) { }) } } + +func TestUrls(t *testing.T) { + tests := []struct { + name string + s []source.PolicySource + kind source.PolicyType + want []string + }{ + { + name: "Returns URLs of the specified kind", + s: []source.PolicySource{ + &source.PolicyUrl{Url: "http://example.com/policy1", Kind: source.PolicyKind}, + &source.PolicyUrl{Url: "http://example.com/data1", Kind: source.DataKind}, + &source.PolicyUrl{Url: "http://example.com/policy2", Kind: source.PolicyKind}, + }, + kind: source.PolicyKind, + want: []string{"http://example.com/policy1", "http://example.com/policy2"}, + }, + { + name: "Returns empty slice when no URLs of the specified kind", + s: []source.PolicySource{ + &source.PolicyUrl{Url: "http://example.com/data1", Kind: source.PolicyType("data")}, + &source.PolicyUrl{Url: "http://example.com/data2", Kind: source.PolicyType("data")}, + }, + kind: source.PolicyKind, + want: []string{}, + }, + { + name: "Returns empty slice when input slice is empty", + s: []source.PolicySource{}, + kind: source.PolicyKind, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := urls(tt.s, tt.kind) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/policy/source/source.go b/internal/policy/source/source.go index 0ffd9d653..b06dcf08c 100644 --- a/internal/policy/source/source.go +++ b/internal/policy/source/source.go @@ -28,6 +28,7 @@ import ( "fmt" "path" "path/filepath" + "strings" "sync" "time" @@ -35,6 +36,7 @@ import ( "github.com/enterprise-contract/go-gather/metadata" fileMetadata "github.com/enterprise-contract/go-gather/metadata/file" gitMetadata "github.com/enterprise-contract/go-gather/metadata/git" + httpMetadata "github.com/enterprise-contract/go-gather/metadata/http" ociMetadata "github.com/enterprise-contract/go-gather/metadata/oci" log "github.com/sirupsen/logrus" "github.com/spf13/afero" @@ -45,14 +47,15 @@ import ( type ( key int - policyKind string + PolicyType string ) const ( DownloaderFuncKey key = 0 - PolicyKind policyKind = "policy" - DataKind policyKind = "data" - ConfigKind policyKind = "config" + PolicyKind PolicyType = "policy" + DataKind PolicyType = "data" + ConfigKind PolicyType = "config" + InlineDataKind PolicyType = "inline-data" ) type downloaderFunc interface { @@ -65,13 +68,13 @@ type PolicySource interface { GetPolicy(ctx context.Context, dest string, showMsg bool) (string, error) PolicyUrl() string Subdir() string + Type() PolicyType } type PolicyUrl struct { // A string containing a go-getter style source url compatible with conftest pull - Url string - // Either "data", "policy", or "config" - Kind policyKind + Url string + Kind PolicyType } // downloadCache is a concurrent map used to cache downloaded files. @@ -83,7 +86,7 @@ type cacheContent struct { err error } -func getPolicyThroughCache(ctx context.Context, s PolicySource, workDir string, dl func(string, string) (metadata.Metadata, error)) (string, error) { +func getPolicyThroughCache(ctx context.Context, s PolicySource, workDir string, dl func(string, string) (metadata.Metadata, error)) (string, metadata.Metadata, error) { sourceUrl := s.PolicyUrl() dest := uniqueDestination(workDir, s.Subdir(), sourceUrl) @@ -101,7 +104,7 @@ func getPolicyThroughCache(ctx context.Context, s PolicySource, workDir string, d, c := dfn.(func() (string, cacheContent))() if c.err != nil { - return "", c.err + return "", c.metadata, c.err } // If the destination directory is different from the source directory, we @@ -110,42 +113,28 @@ func getPolicyThroughCache(ctx context.Context, s PolicySource, workDir string, fs := utils.FS(ctx) base := filepath.Dir(dest) if err := fs.MkdirAll(base, 0755); err != nil { - return "", err + return "", nil, err } if symlinkableFS, ok := fs.(afero.Symlinker); ok { log.Debugf("Symlinking %s to %s", d, dest) if err := symlinkableFS.SymlinkIfPossible(d, dest); err != nil { - return "", err + return "", nil, err } - if c.metadata != nil { - if _, ok := c.metadata.(*gitMetadata.GitMetadata); ok { - log.Debugf("SHA for source(%s): %s\n", s.PolicyUrl(), c.metadata.(*gitMetadata.GitMetadata).LatestCommit) - } - if _, ok := c.metadata.(*ociMetadata.OCIMetadata); ok { - log.Debugf("Image digest for source(%s): %s\n", s.PolicyUrl(), c.metadata.(*ociMetadata.OCIMetadata).Digest) - } - } - return dest, nil + logMetadata(c.metadata) + return dest, c.metadata, nil } else { log.Debugf("Filesystem does not support symlinking: %q, re-downloading instead", fs.Name()) m, err := dl(sourceUrl, dest) - if _, ok := m.(*gitMetadata.GitMetadata); ok { - log.Debugf("SHA for source(%s): %s\n", s.PolicyUrl(), m.(*gitMetadata.GitMetadata).LatestCommit) - } - return dest, err + logMetadata(m) + return dest, m, err } } if c.metadata != nil { - if _, ok := c.metadata.(*gitMetadata.GitMetadata); ok { - log.Debugf("SHA for source(%s): %s\n", s.PolicyUrl(), c.metadata.(*gitMetadata.GitMetadata).LatestCommit) - } - if _, ok := c.metadata.(*ociMetadata.OCIMetadata); ok { - log.Debugf("Image digest for source(%s): %s\n", s.PolicyUrl(), c.metadata.(*ociMetadata.OCIMetadata).Digest) - } + logMetadata(c.metadata) } - return d, c.err + return d, c.metadata, c.err } // GetPolicies clones the repository for a given PolicyUrl @@ -158,7 +147,17 @@ func (p *PolicyUrl) GetPolicy(ctx context.Context, workDir string, showMsg bool) return downloader.Download(ctx, dest, source, showMsg) } - return getPolicyThroughCache(ctx, p, workDir, dl) + dest, metadata, err := getPolicyThroughCache(ctx, p, workDir, dl) + if err != nil { + return "", err + } + + p.Url, err = getPinnedUrl(p.Url, metadata) + if err != nil { + return "", err + } + + return dest, err } func (p *PolicyUrl) PolicyUrl() string { @@ -170,6 +169,62 @@ func (p *PolicyUrl) Subdir() string { return string(p.Kind) } +func (p PolicyUrl) Type() PolicyType { + return p.Kind +} + +// getPinnedUrl returns the URL with the pinned commit or digest. +// TODO: Move this to the go-gather library. +func getPinnedUrl(u string, m metadata.Metadata) (string, error) { + if m == nil { + return "", fmt.Errorf("metadata is nil") + } + + if len(u) == 0 { + return "", fmt.Errorf("url is empty") + } + + switch t := m.(type) { + case *gitMetadata.GitMetadata: + + return strings.SplitN(u, "?ref=", 2)[0] + "?ref=" + t.LatestCommit, nil + + case *ociMetadata.OCIMetadata: + for _, scheme := range []string{"oci::", "oci://", "https://"} { + u = strings.TrimPrefix(u, scheme) + } + parts := strings.Split(u, "@") + if len(parts) > 1 { + u = parts[0] + } + return fmt.Sprintf("oci://%s@%s", u, t.Digest), nil + + case *httpMetadata.HTTPMetadata: + return u, nil + case *fileMetadata.FileMetadata: + return u, nil + case *fileMetadata.DirectoryMetadata: + return u, nil + default: + return "", fmt.Errorf("unknown metadata type") + } +} + +func logMetadata(m metadata.Metadata) { + if m != nil { + switch v := m.(type) { + case *gitMetadata.GitMetadata: + log.Debugf("SHA: %s\n", v.LatestCommit) + case *ociMetadata.OCIMetadata: + log.Debugf("Image digest: %s\n", v.Digest) + case *fileMetadata.FileMetadata: + log.Debugf("File path: %s\n", v.Path) + case *fileMetadata.DirectoryMetadata: + log.Debugf("Directory path: %s\n", v.Path) + } + } +} + func uniqueDestination(rootDir string, subdir string, sourceUrl string) string { return path.Join(rootDir, subdir, uniqueDir(sourceUrl)) } @@ -206,6 +261,28 @@ func (s inlineData) GetPolicy(ctx context.Context, workDir string, showMsg bool) return m, afero.WriteFile(fs, f, s.source, 0400) } + dest, _, err := getPolicyThroughCache(ctx, s, workDir, dl) + return dest, err +} + +func (s inlineData) GetPolicyWithMetadata(ctx context.Context, workDir string, showMsg bool) (string, metadata.Metadata, error) { + dl := func(source string, dest string) (metadata.Metadata, error) { + fs := utils.FS(ctx) + + if err := fs.MkdirAll(dest, 0755); err != nil { + return nil, err + } + + f := path.Join(dest, "rule_data.json") + m := &fileMetadata.FileMetadata{ + Path: dest, + Size: int64(len(dest)), + SHA: "", + } + + return m, afero.WriteFile(fs, f, s.source, 0400) + } + return getPolicyThroughCache(ctx, s, workDir, dl) } @@ -217,17 +294,21 @@ func (s inlineData) Subdir() string { return "data" } +func (inlineData) Type() PolicyType { + return InlineDataKind +} + // FetchPolicySources returns an array of policy sources func FetchPolicySources(s ecc.Source) ([]PolicySource, error) { policySources := make([]PolicySource, 0, len(s.Policy)+len(s.Data)) for _, policySourceUrl := range s.Policy { - url := PolicyUrl{Url: policySourceUrl, Kind: "policy"} + url := PolicyUrl{Url: policySourceUrl, Kind: PolicyKind} policySources = append(policySources, &url) } for _, dataSourceUrl := range s.Data { - url := PolicyUrl{Url: dataSourceUrl, Kind: "data"} + url := PolicyUrl{Url: dataSourceUrl, Kind: DataKind} policySources = append(policySources, &url) } diff --git a/internal/policy/source/source_test.go b/internal/policy/source/source_test.go index 27bf0bda5..53b435609 100644 --- a/internal/policy/source/source_test.go +++ b/internal/policy/source/source_test.go @@ -29,6 +29,10 @@ import ( ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" "github.com/enterprise-contract/go-gather/metadata" + fileMetadata "github.com/enterprise-contract/go-gather/metadata/file" + gitMetadata "github.com/enterprise-contract/go-gather/metadata/git" + httpMetadata "github.com/enterprise-contract/go-gather/metadata/http" + ociMetadata "github.com/enterprise-contract/go-gather/metadata/oci" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -49,7 +53,7 @@ type mockDownloader struct { func (m *mockDownloader) Download(_ context.Context, dest string, sourceUrl string, showMsg bool) (metadata.Metadata, error) { args := m.Called(dest, sourceUrl, showMsg) - return nil, args.Error(0) + return args.Get(0).(metadata.Metadata), args.Error(1) } func TestGetPolicy(t *testing.T) { @@ -57,30 +61,34 @@ func TestGetPolicy(t *testing.T) { name string sourceUrl string dest string + metadata metadata.Metadata err error }{ { name: "Gets policies", sourceUrl: "https://example.com/user/foo.git", dest: "/tmp/ec-work-1234/policy/[0-9a-f]+", + metadata: &fileMetadata.FileMetadata{}, err: nil, }, { name: "Gets policies with getter style source url", sourceUrl: "git::https://example.com/user/foo.git//subdir?ref=devel", dest: "/tmp/ec-work-1234/policy/[0-9a-f]+", + metadata: &fileMetadata.FileMetadata{}, err: nil, }, { name: "Fails fetching the policy", sourceUrl: "failure", dest: "/tmp/ec-work-1234/policy/[0-9a-f]+", + metadata: &fileMetadata.FileMetadata{}, err: errors.New("expected"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := PolicyUrl{Url: tt.sourceUrl, Kind: "policy"} + p := PolicyUrl{Url: tt.sourceUrl, Kind: PolicyKind} dl := mockDownloader{} dl.On("Download", mock.MatchedBy(func(dest string) bool { @@ -90,7 +98,7 @@ func TestGetPolicy(t *testing.T) { } return matched - }), tt.sourceUrl, false).Return(tt.err) + }), tt.sourceUrl, false).Return(tt.metadata, tt.err) _, err := p.GetPolicy(usingDownloader(context.TODO(), &dl), "/tmp/ec-work-1234", false) if tt.err == nil { @@ -146,12 +154,12 @@ func TestFetchPolicySources(t *testing.T) { Data: []string{"github.com/org/repo1//data/", "github.com/org/repo2//data/", "github.com/org/repo3//data/"}, }, expected: []PolicySource{ - &PolicyUrl{Url: "github.com/org/repo1//policy/", Kind: "policy"}, - &PolicyUrl{Url: "github.com/org/repo2//policy/", Kind: "policy"}, - &PolicyUrl{Url: "github.com/org/repo3//policy/", Kind: "policy"}, - &PolicyUrl{Url: "github.com/org/repo1//data/", Kind: "data"}, - &PolicyUrl{Url: "github.com/org/repo2//data/", Kind: "data"}, - &PolicyUrl{Url: "github.com/org/repo3//data/", Kind: "data"}, + &PolicyUrl{Url: "github.com/org/repo1//policy/", Kind: PolicyKind}, + &PolicyUrl{Url: "github.com/org/repo2//policy/", Kind: PolicyKind}, + &PolicyUrl{Url: "github.com/org/repo3//policy/", Kind: PolicyKind}, + &PolicyUrl{Url: "github.com/org/repo1//data/", Kind: DataKind}, + &PolicyUrl{Url: "github.com/org/repo2//data/", Kind: DataKind}, + &PolicyUrl{Url: "github.com/org/repo3//data/", Kind: DataKind}, }, err: nil, }, @@ -164,8 +172,8 @@ func TestFetchPolicySources(t *testing.T) { RuleData: &extv1.JSON{Raw: []byte(`"foo":"bar"`)}, }, expected: []PolicySource{ - &PolicyUrl{Url: "github.com/org/repo1//policy/", Kind: "policy"}, - &PolicyUrl{Url: "github.com/org/repo1//data/", Kind: "data"}, + &PolicyUrl{Url: "github.com/org/repo1//policy/", Kind: PolicyKind}, + &PolicyUrl{Url: "github.com/org/repo1//data/", Kind: DataKind}, inlineData{source: []byte("{\"rule_data__configuration__\":\"foo\":\"bar\"}")}, }, err: nil, @@ -190,6 +198,10 @@ func (mockPolicySource) GetPolicy(_ context.Context, _ string, _ bool) (string, return "", nil } +func (mockPolicySource) GetPolicyWithMetadata(_ context.Context, _ string, _ bool) (string, metadata.Metadata, error) { + return "", nil, nil +} + func (mockPolicySource) PolicyUrl() string { return "" } @@ -198,6 +210,10 @@ func (mockPolicySource) Subdir() string { return "mock" } +func (mockPolicySource) Type() PolicyType { + return PolicyKind +} + func TestGetPolicyThroughCache(t *testing.T) { test := func(t *testing.T, fs afero.Fs, expectedDownloads int) { downloadCache.Range(func(key, _ any) bool { @@ -219,10 +235,10 @@ func TestGetPolicyThroughCache(t *testing.T) { return nil, afero.WriteFile(fs, filepath.Join(dest, "data.json"), data, 0400) } - s1, err := getPolicyThroughCache(ctx, &mockPolicySource{}, "/workdir1", dl) + s1, _, err := getPolicyThroughCache(ctx, &mockPolicySource{}, "/workdir1", dl) require.NoError(t, err) - s2, err := getPolicyThroughCache(ctx, &mockPolicySource{}, "/workdir2", dl) + s2, _, err := getPolicyThroughCache(ctx, &mockPolicySource{}, "/workdir2", dl) require.NoError(t, err) assert.NotEqual(t, s1, s2) @@ -258,3 +274,242 @@ func TestGetPolicyThroughCache(t *testing.T) { test(t, afero.NewMemMapFs(), 2) }) } + +// TestGetPinnedURL tests the GetPinnedURL function with various inputs and metadata types. +func TestGetPinnedURL(t *testing.T) { + testCases := []struct { + name string + url string + metadata metadata.Metadata + expected string + hasError bool + }{ + // Git Metadata Tests + { + name: "Git URL with git:: prefix and ref", + url: "git::https://test-url.git?ref=abc1234", + metadata: &gitMetadata.GitMetadata{ + LatestCommit: "def456", + }, + expected: "git::https://test-url.git?ref=def456", + hasError: false, + }, + { + name: "Git URL without git:: prefix", + url: "https://test-url.git?ref=abc1234", + metadata: &gitMetadata.GitMetadata{ + LatestCommit: "def456", + }, + expected: "https://test-url.git?ref=def456", + hasError: false, + }, + { + name: "Git URL with git:: prefix and path suffix", + url: "git::https://test-url.git//path/to/file?ref=abc1234", + metadata: &gitMetadata.GitMetadata{ + LatestCommit: "ghi789", + }, + expected: "git::https://test-url.git//path/to/file?ref=ghi789", + hasError: false, + }, + { + name: "Git URL with git:: prefix, path suffix, and existing SHA (should ignore SHA)", + url: "git::https://test-url.git//path/to/file?ref=abc1234@sha256:xyz", + metadata: &gitMetadata.GitMetadata{ + LatestCommit: "ghi789", + }, + expected: "git::https://test-url.git//path/to/file?ref=ghi789", + hasError: false, + }, + + // OCI Metadata Tests + { + name: "OCI URL with oci:: prefix and repo tag", + url: "oci::registry/policy:latest", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy:latest@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:// prefix and repo tag", + url: "oci://registry/org/policy:dev", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/org/policy:dev@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:: prefix, path suffix, and repo tag", + url: "oci::registry/policy:main", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy:main@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:: prefix and path suffix without repo tag", + url: "oci::registry/policy", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL without prefix and with repo tag", + url: "registry/policy:latest", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy:latest@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL without prefix and without repo tag", + url: "registry/policy", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:: prefix and path suffix without tag", + url: "oci://registry/policy", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:// prefix and repo tag with existing digest", + url: "oci://registry/policy:bar@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy:bar@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:: prefix and path suffix with existing digest", + url: "oci::registry/policy:baz@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy:baz@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with oci:: prefix and path suffix without tag", + url: "oci::registry/policy", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + { + name: "OCI URL with multiple path suffixes and repo tag", + url: "oci://registry/policy:beta", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + }, + expected: "oci://registry/policy:beta@sha256:c04c1f5ea75e869e2da7150c927d0c8649790b2e3c82e6ff317d4cfa068c1649", + hasError: false, + }, + + // HTTP and File Metadata Tests + { + name: "HTTP URL", + url: "https://example.org/policy.yaml", + metadata: &httpMetadata.HTTPMetadata{}, + expected: "https://example.org/policy.yaml", + hasError: false, + }, + { + name: "HTTP Metadata with query", + url: "https://example.org/policy.yaml?version=1.0", + metadata: &httpMetadata.HTTPMetadata{}, + expected: "https://example.org/policy.yaml?version=1.0", + hasError: false, + }, + { + name: "File Metadata with regular URL without tag", + url: "/path/to/policy.yaml", + metadata: &fileMetadata.FileMetadata{}, + expected: "/path/to/policy.yaml", + hasError: false, + }, + + // Error Cases + { + name: "Nil Metadata", + url: "oci::registry/policy:latest", + metadata: nil, + expected: "", + hasError: true, + }, + { + name: "Empty URL", + url: "", + metadata: &ociMetadata.OCIMetadata{Digest: "sha256:abc1234"}, + expected: "", + hasError: true, + }, + { + name: "Unknown Metadata Type", + url: "oci::registry/policy:latest", + metadata: nil, + expected: "", + hasError: true, + }, + { + name: "OCI URL with oci:: prefix but missing repository", + url: "oci:://path/to/file:dev@sha256:abc1234", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:uvw789", + }, + expected: "oci:////path/to/file:dev@sha256:uvw789", + hasError: false, // Depending on implementation, may or may not error + }, + { + name: "OCI URL with multiple colons in path tag", + url: "oci://registry/policy//path:to:file:dev@sha256:abc1234", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:xyz123", + }, + expected: "oci://registry/policy//path:to:file:dev@sha256:xyz123", + hasError: false, + }, + { + name: "OCI URL without digest but metadata provides digest", + url: "oci::registry/policy:latest", + metadata: &ociMetadata.OCIMetadata{ + Digest: "sha256:missingdigest", + }, + expected: "oci://registry/policy:latest@sha256:missingdigest", + hasError: false, + }, + } + + for _, tc := range testCases { + tc := tc // Capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() // Run tests in parallel where possible + + got, err := getPinnedUrl(tc.url, tc.metadata) + if (err != nil) != tc.hasError { + t.Errorf("GetPinnedURL() \nerror = %v, \nexpected error = %v", err, tc.hasError) + t.Fatalf("GetPinnedURL() \nerror = %v, \nexpected error = %v", err, tc.hasError) + } + if got != tc.expected { + t.Errorf("GetPinnedURL() = %q\ninput = %q\nexpected = %q\ngot = %q", got, tc.url, tc.expected, got) + } + }) + } +}