diff --git a/pkg/extensions/config/sync/config.go b/pkg/extensions/config/sync/config.go index e22e2f08..c7266b0a 100644 --- a/pkg/extensions/config/sync/config.go +++ b/pkg/extensions/config/sync/config.go @@ -32,12 +32,19 @@ type RegistryConfig struct { MaxRetries *int RetryDelay *time.Duration OnlySigned *bool + SyncLegacyCosignTags *bool // when unset, defaults to true CredentialHelper string PreserveDigest bool // sync without converting SyncTimeout time.Duration // overall HTTP client timeout for all sync operations ResponseHeaderTimeout time.Duration `yaml:"-"` // response header timeout; set in root.go } +// ShouldSyncLegacyCosignTags returns whether to sync legacy cosign tags (e.g. sha256-.sig/sbom). +// Default is true when SyncLegacyCosignTags is unset (nil). +func (r RegistryConfig) ShouldSyncLegacyCosignTags() bool { + return r.SyncLegacyCosignTags == nil || *r.SyncLegacyCosignTags +} + type Content struct { Prefix string Tags *Tags diff --git a/pkg/extensions/config/sync/config_test.go b/pkg/extensions/config/sync/config_test.go new file mode 100644 index 00000000..0d44875e --- /dev/null +++ b/pkg/extensions/config/sync/config_test.go @@ -0,0 +1,31 @@ +package sync_test + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + syncconf "zotregistry.dev/zot/v2/pkg/extensions/config/sync" +) + +func TestRegistryConfig_ShouldSyncLegacyCosignTags(t *testing.T) { + Convey("ShouldSyncLegacyCosignTags", t, func() { + Convey("returns true when SyncLegacyCosignTags is nil (default)", func() { + cfg := syncconf.RegistryConfig{} + So(cfg.SyncLegacyCosignTags, ShouldBeNil) + So(cfg.ShouldSyncLegacyCosignTags(), ShouldBeTrue) + }) + + Convey("returns true when SyncLegacyCosignTags is true", func() { + v := true + cfg := syncconf.RegistryConfig{SyncLegacyCosignTags: &v} + So(cfg.ShouldSyncLegacyCosignTags(), ShouldBeTrue) + }) + + Convey("returns false when SyncLegacyCosignTags is false", func() { + v := false + cfg := syncconf.RegistryConfig{SyncLegacyCosignTags: &v} + So(cfg.ShouldSyncLegacyCosignTags(), ShouldBeFalse) + }) + }) +} diff --git a/pkg/extensions/sync/service.go b/pkg/extensions/sync/service.go index 117aa190..009af113 100644 --- a/pkg/extensions/sync/service.go +++ b/pkg/extensions/sync/service.go @@ -343,12 +343,16 @@ func (service *BaseService) SyncReferrers(ctx context.Context, repo string, service.log.Info().Str("remote", remoteURL).Str("repository", repo).Str("subject", subjectDigestStr). Interface("reference types", referenceTypes).Msg("syncing reference for image") - tags, err := service.getTags(ctx, remoteRepo, false) - if err != nil { - service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", repo). - Err(err).Msg("error while getting tags for repo") + var tags []string + if service.config.ShouldSyncLegacyCosignTags() { + var err error + tags, err = service.getTags(ctx, remoteRepo, false) + if err != nil { + service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", repo). + Err(err).Msg("error while getting tags for repo") - return err + return err + } } remoteImageRef, err := service.remote.GetImageReference(remoteRepo, subjectDigestStr) @@ -367,7 +371,7 @@ func (service *BaseService) SyncReferrers(ctx context.Context, repo string, return err } - if err := service.syncReferrers(ctx, tags, repo, remoteRepo, localImageRef, remoteImageRef); err != nil { + if err := service.syncReferrers(ctx, tags, repo, remoteRepo, localImageRef, remoteImageRef, false); err != nil { service.log.Error().Err(err).Str("errortype", common.TypeOf(err)). Str("repo", repo).Str("reference", subjectDigestStr).Msg("failed to sync referrers") @@ -430,7 +434,7 @@ func (service *BaseService) SyncRepo(ctx context.Context, repo string) error { return ctx.Err() } - // skip referrers, they are synced in syncTagAndReferrers. + // skip referrers tags and cosign tags here; they are synced via syncReferrers in syncImage. if common.IsCosignTag(tag) || common.IsReferrersTag(tag) { continue } @@ -460,7 +464,7 @@ func (service *BaseService) SyncRepo(ctx context.Context, repo string) error { } func (service *BaseService) syncRef(ctx context.Context, localRepo string, remoteImageRef, localImageRef ref.Ref, - remoteDigest godigest.Digest, recursive bool, + remoteDigest godigest.Digest, ) (bool, error) { var reference string @@ -475,9 +479,6 @@ func (service *BaseService) syncRef(ctx context.Context, localRepo string, remot } copyOpts := []regclient.ImageOpts{} - if recursive { - copyOpts = append(copyOpts, regclient.ImageWithReferrers()) - } // check if image is already synced skipImage, err = service.destination.CanSkipImage(localRepo, reference, remoteDigest) @@ -570,17 +571,6 @@ func (service *BaseService) syncImage(ctx context.Context, localRepo, remoteRepo // if onlySigned flag true in config and the image is not itself a signature if checkIsSigned { - // if need tags for checking signature (onlySigned option true) or needs for referrers - if len(repoTags) == 0 { - repoTags, err = service.getTags(ctx, remoteRepo, false) - if err != nil { - service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo). - Err(err).Msg("error while getting tags for repo") - - return err - } - } - referrers, err := service.rc.ReferrerList(ctx, remoteImageRef) if err != nil { service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo). @@ -589,11 +579,24 @@ func (service *BaseService) syncImage(ctx context.Context, localRepo, remoteRepo return err } - // verify repo contains a cosign signature for this manifest - hasCosignSignature := slices.Contains(repoTags, fmt.Sprintf("%s-%s.sig", remoteDigest.Algorithm(), - remoteDigest.Encoded())) + isSigned := hasSignatureReferrers(referrers) + if service.config.ShouldSyncLegacyCosignTags() { + // legacy fallback: verify repo contains a cosign signature tag for this manifest + if len(repoTags) == 0 { + repoTags, err = service.getTags(ctx, remoteRepo, false) + if err != nil { + service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo). + Err(err).Msg("error while getting tags for repo") - isSigned := hasSignatureReferrers(referrers) || hasCosignSignature + return err + } + } + + hasCosignSignature := slices.Contains(repoTags, fmt.Sprintf("%s-%s.sig", remoteDigest.Algorithm(), + remoteDigest.Encoded())) + + isSigned = isSigned || hasCosignSignature + } if !isSigned { // skip unsigned images service.log.Info().Str("image", remoteImageRef.CommonName()). @@ -617,13 +620,13 @@ func (service *BaseService) syncImage(ctx context.Context, localRepo, remoteRepo defer service.destination.CleanupImage(localImageRef, localRepo) //nolint: errcheck // first sync image - skipped, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, localDigest, false) + skipped, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, localDigest) if err != nil { return err } if withReferrers { - _ = service.syncReferrers(ctx, repoTags, localRepo, remoteRepo, localImageRef, remoteImageRef) + _ = service.syncReferrers(ctx, repoTags, localRepo, remoteRepo, localImageRef, remoteImageRef, true) } // convert image to oci if needed @@ -681,15 +684,15 @@ func (service *BaseService) getTags(ctx context.Context, repo string, noCache bo return tags, nil } -// syncs all referrers recursively. +// syncs referrers of the given subject, when recursive is true also syncs referrers of those referrers. func (service *BaseService) syncReferrers(ctx context.Context, tags []string, localRepo, remoteRepo string, - localImageRef ref.Ref, remoteImageRef ref.Ref, + localImageRef ref.Ref, remoteImageRef ref.Ref, recursive bool, ) error { seen := []string{} var err error - if len(tags) == 0 { + if service.config.ShouldSyncLegacyCosignTags() && len(tags) == 0 { tags, err = service.getTags(ctx, remoteRepo, false) if err != nil { service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo). @@ -700,11 +703,11 @@ func (service *BaseService) syncReferrers(ctx context.Context, tags []string, lo } var inner func(ctx context.Context, tags []string, localRepo, remoteRepo string, - localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string, + localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string, recursive bool, ) error inner = func(ctx context.Context, tags []string, localRepo, remoteRepo string, - localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string, + localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string, recursive bool, ) error { var err error @@ -737,39 +740,45 @@ func (service *BaseService) syncReferrers(ctx context.Context, tags []string, lo localImageRef = localImageRef.SetDigest(desc.Digest.String()) - _, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, desc.Digest, false) + _, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, desc.Digest) if err != nil { service.log.Error().Err(err).Str("errortype", common.TypeOf(err)). Str("repo", localRepo).Str("local reference", localImageRef.Tag). Str("remote reference", remoteImageRef.Tag).Msg("failed to sync referrer") } - _ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen) + if recursive { + _ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen, recursive) + } } - // try cosign - prefix := fmt.Sprintf("%s-%s.", remoteDigest.Algorithm(), remoteDigest.Encoded()) - for _, tag := range tags { - if strings.Contains(tag, prefix) { - remoteImageRef = remoteImageRef.SetTag(tag) + if service.config.ShouldSyncLegacyCosignTags() { + // try legacy cosign-style tags (sha256-.) + prefix := fmt.Sprintf("%s-%s.", remoteDigest.Algorithm(), remoteDigest.Encoded()) + for _, tag := range tags { + if strings.Contains(tag, prefix) { + remoteImageRef = remoteImageRef.SetTag(tag) - localImageRef = localImageRef.SetTag(tag) + localImageRef = localImageRef.SetTag(tag) - _, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, remoteDigest, true) - if err != nil { - service.log.Error().Err(err).Str("errortype", common.TypeOf(err)). - Str("repo", localRepo).Str("local reference", localImageRef.Tag). - Str("remote reference", remoteImageRef.Tag).Msg("failed to sync referrer") + _, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, remoteDigest) + if err != nil { + service.log.Error().Err(err).Str("errortype", common.TypeOf(err)). + Str("repo", localRepo).Str("local reference", localImageRef.Tag). + Str("remote reference", remoteImageRef.Tag).Msg("failed to sync referrer") + } + + if recursive { + _ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen, recursive) + } } - - _ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen) } } return err } - return inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen) + return inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen, recursive) } func (service *BaseService) ResetCatalog() { diff --git a/pkg/extensions/sync/sync_internal_test.go b/pkg/extensions/sync/sync_internal_test.go index 06cafca8..25a1b10c 100644 --- a/pkg/extensions/sync/sync_internal_test.go +++ b/pkg/extensions/sync/sync_internal_test.go @@ -18,6 +18,7 @@ import ( godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/regclient/regclient" "github.com/regclient/regclient/types/ref" . "github.com/smartystreets/goconvey/convey" @@ -93,6 +94,8 @@ func TestService(t *testing.T) { }) Convey("test SyncReferrers ReferrerList error", t, func() { + // SyncReferrers is the on-demand API; it calls syncReferrers(..., recursive=false) + // so only direct referrers are synced (no referrers-of-referrers). conf := syncconf.RegistryConfig{ URLs: []string{"http://localhost"}, } @@ -250,7 +253,13 @@ func TestService(t *testing.T) { // Create an invalid remote reference that will cause ReferrerList to fail with "ref is not set" error remoteImageRef := ref.Ref{} - err = service.syncReferrers(ctx, []string{"tag"}, "localrepo", "remoterepo", localImageRef, remoteImageRef) + err = service.syncReferrers(ctx, []string{"tag"}, "localrepo", "remoterepo", localImageRef, remoteImageRef, false) + + // The error should be "ref is not set" as defined in regclient ReferrerList function + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "ref is not set") + + err = service.syncReferrers(ctx, []string{"tag"}, "localrepo", "remoterepo", localImageRef, remoteImageRef, true) // The error should be "ref is not set" as defined in regclient ReferrerList function So(err, ShouldNotBeNil) @@ -635,6 +644,140 @@ func TestService(t *testing.T) { }) } +func TestSyncLegacyCosignTagsSyncReferrers(t *testing.T) { + Convey("SyncLegacyCosignTags=false skips getTags and the digest-tag loop in SyncReferrers", t, func() { + getTagsCallCount := 0 + syncLegacyFalse := false + + conf := syncconf.RegistryConfig{ + URLs: []string{"http://localhost"}, + SyncLegacyCosignTags: &syncLegacyFalse, + } + + service, err := New(conf, "", nil, t.TempDir(), storage.StoreController{}, mocks.MetaDBMock{}, log.NewTestLogger()) + So(err, ShouldBeNil) + + service.rc = regclient.New() + + mockRemote := &mocks.SyncRemoteMock{ + GetTagsFn: func(ctx context.Context, repo string) ([]string, error) { + getTagsCallCount++ + return nil, errors.New("getTags must not be called when SyncLegacyCosignTags=false") + }, + GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) { + return ref.New(repo + "@" + tag) + }, + GetDigestFn: func(ctx context.Context, repo, tag string) (godigest.Digest, error) { + return godigest.Digest("sha256:" + strings.Repeat("a", 64)), nil + }, + } + service.remote = mockRemote + + mockDest := &mocks.SyncDestinationMock{ + GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) { + return ref.New("local/" + repo + "@" + tag) + }, + CommitAllFn: func(repo string, imageReference ref.Ref) error { + return nil + }, + } + service.destination = mockDest + + ctx := context.Background() + digest := "sha256:" + strings.Repeat("a", 64) + + err = service.SyncReferrers(ctx, "repo", digest, nil) + // We expect an error from rc.ReferrerList since it's not connected to a registry, + // but what we really care about is that getTags was NOT called. + So(getTagsCallCount, ShouldEqual, 0) + }) + + Convey("SyncLegacyCosignTags=true (default) calls getTags in SyncReferrers", t, func() { + getTagsCallCount := 0 + syncLegacyTrue := true + + conf := syncconf.RegistryConfig{ + URLs: []string{"http://localhost"}, + SyncLegacyCosignTags: &syncLegacyTrue, + } + + service, err := New(conf, "", nil, t.TempDir(), storage.StoreController{}, mocks.MetaDBMock{}, log.NewTestLogger()) + So(err, ShouldBeNil) + + service.rc = regclient.New() + + mockRemote := &mocks.SyncRemoteMock{ + GetTagsFn: func(ctx context.Context, repo string) ([]string, error) { + getTagsCallCount++ + return []string{"tag1"}, nil + }, + GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) { + return ref.New(repo + "@" + tag) + }, + } + service.remote = mockRemote + + mockDest := &mocks.SyncDestinationMock{ + GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) { + return ref.New("local/" + repo + "@" + tag) + }, + } + service.destination = mockDest + + ctx := context.Background() + digest := "sha256:" + strings.Repeat("a", 64) + + err = service.SyncReferrers(ctx, "repo", digest, nil) + // We don't care if it fails, only that getTags was called. + So(getTagsCallCount, ShouldEqual, 1) + }) +} + +func TestOnDemandSyncReferrersNonRecursive(t *testing.T) { + Convey("SyncReferrers (on-demand) uses non-recursive sync", t, func() { + // Verify that recursive calls are not made. + // We use a non-nil regclient to avoid panics. + syncLegacyFalse := false + + conf := syncconf.RegistryConfig{ + URLs: []string{"http://localhost"}, + SyncLegacyCosignTags: &syncLegacyFalse, + } + + service, err := New(conf, "", nil, t.TempDir(), storage.StoreController{}, mocks.MetaDBMock{}, log.NewTestLogger()) + So(err, ShouldBeNil) + + service.rc = regclient.New() + + mockRemote := &mocks.SyncRemoteMock{ + GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) { + return ref.New(repo + "@" + tag) + }, + GetDigestFn: func(ctx context.Context, repo, tag string) (godigest.Digest, error) { + return godigest.Digest("sha256:" + strings.Repeat("a", 64)), nil + }, + GetTagsFn: func(ctx context.Context, repo string) ([]string, error) { + return []string{}, nil + }, + } + service.remote = mockRemote + + mockDest := &mocks.SyncDestinationMock{ + GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) { + return ref.New("local/" + repo + "@" + tag) + }, + } + service.destination = mockDest + + ctx := context.Background() + digest := "sha256:" + strings.Repeat("a", 64) + + err = service.SyncReferrers(ctx, "repo", digest, nil) + // It will fail because rc is not connected, but it shouldn't panic. + So(err, ShouldNotBeNil) + }) +} + func TestDestinationRegistry(t *testing.T) { Convey("make StoreController", t, func() { dir := t.TempDir() diff --git a/pkg/extensions/sync/sync_test.go b/pkg/extensions/sync/sync_test.go index 19b4809d..8ad92d3e 100644 --- a/pkg/extensions/sync/sync_test.go +++ b/pkg/extensions/sync/sync_test.go @@ -5425,6 +5425,304 @@ func TestSyncedSignaturesMetaDB(t *testing.T) { }) } +func TestSyncLegacyCosignTags(t *testing.T) { + Convey("SyncLegacyCosignTags controls whether legacy cosign/SBOM tags are synced", t, func() { + sctlr, srcBaseURL, srcDir, _ := makeUpstreamServer(t, false, false) + scm := test.NewControllerManager(sctlr) + scm.StartAndWait(sctlr.Config.HTTP.Port) + defer scm.StopServer() + + // Get image digest and add legacy SBOM tag on source (sha256-.sbom) + resp, err := resty.R().Get(srcBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + imageDigestStr := resp.Header().Get("Docker-Content-Digest") + So(imageDigestStr, ShouldNotBeEmpty) + imageDigest, err := godigest.Parse(imageDigestStr) + So(err, ShouldBeNil) + + srcPort := getPortFromBaseURL(srcBaseURL) + attachSBOM(srcDir, srcPort, testImage, imageDigest) + + legacyTag := imageDigest.Algorithm().String() + "-" + imageDigest.Encoded() + ".sbom" + + tlsVerify := false + regex := ".*" + semver := false + + Convey("With SyncLegacyCosignTags true (default), legacy tag is synced", func() { + syncLegacyTrue := true + syncRegistryConfig := syncconf.RegistryConfig{ + Content: []syncconf.Content{ + {Prefix: "**", Tags: &syncconf.Tags{Regex: ®ex, Semver: &semver}}, + }, + URLs: []string{srcBaseURL}, + TLSVerify: &tlsVerify, + OnDemand: true, + SyncLegacyCosignTags: &syncLegacyTrue, + } + defaultVal := true + syncConfig := &syncconf.Config{ + Enable: &defaultVal, + Registries: []syncconf.RegistryConfig{syncRegistryConfig}, + } + dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig) + dcm := test.NewControllerManager(dctlr) + dcm.StartAndWait(dctlr.Config.HTTP.Port) + defer dcm.StopServer() + + // Trigger image sync + resp, err = destClient.R().Get(destBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // Trigger referrers sync (pulls legacy tags when SyncLegacyCosignTags is true) + resp, err = destClient.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + imageDigestStr) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Verify via storage (avoid GET manifest for legacy tag, which can trigger on-demand sync) + indexPath := path.Join(destDir, testImage, "index.json") + found := false + + for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(250 * time.Millisecond) { + indexBuf, err := os.ReadFile(indexPath) + if err != nil { + continue + } + + var destIndex ispec.Index + if json.Unmarshal(indexBuf, &destIndex) != nil { + continue + } + + for _, desc := range destIndex.Manifests { + if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacyTag { + found = true + + break + } + } + + if found { + break + } + } + + if !found { + t.Fatalf("legacy tag %q should be present on destination index", legacyTag) + } + }) + + Convey("With SyncLegacyCosignTags false, legacy tag is not synced", func() { + syncLegacyFalse := false + pollInterval, _ := time.ParseDuration("5s") + syncRegistryConfig := syncconf.RegistryConfig{ + Content: []syncconf.Content{ + {Prefix: "**", Tags: &syncconf.Tags{Regex: ®ex, Semver: &semver}}, + }, + URLs: []string{srcBaseURL}, + TLSVerify: &tlsVerify, + OnDemand: false, + PollInterval: pollInterval, + SyncLegacyCosignTags: &syncLegacyFalse, + } + defaultVal := true + syncConfig := &syncconf.Config{ + Enable: &defaultVal, + Registries: []syncconf.RegistryConfig{syncRegistryConfig}, + } + dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig) + dcm := test.NewControllerManager(dctlr) + dcm.StartAndWait(dctlr.Config.HTTP.Port) + defer dcm.StopServer() + + // Wait for periodic sync to complete (do not call GET manifest for legacy tag to avoid on-demand sync) + found, err := test.ReadLogFileAndSearchString(dctlr.Config.Log.Output, "finished syncing repo", 30*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + // Poll until testImage repo exists on destination (periodic sync may sync multiple repos) + var resp *resty.Response + for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(500 * time.Millisecond) { + resp, err = destClient.R().Get(destBaseURL + "/v2/" + testImage + "/tags/list") + if err == nil && resp != nil && resp.StatusCode() == http.StatusOK { + break + } + } + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Verify via tags/list: legacy tag must not be present + var destTags TagsList + So(json.Unmarshal(resp.Body(), &destTags), ShouldBeNil) + So(destTags.Tags, ShouldNotContain, legacyTag) + + // Verify via storage: index.json must not reference the legacy tag + indexPath := path.Join(destDir, testImage, "index.json") + indexBuf, err := os.ReadFile(indexPath) + So(err, ShouldBeNil) + + var destIndex ispec.Index + So(json.Unmarshal(indexBuf, &destIndex), ShouldBeNil) + + for _, desc := range destIndex.Manifests { + if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacyTag { + t.Fatalf("legacy tag %q should not be in destination index", legacyTag) + } + } + }) + }) +} + +func TestSyncLegacyCosignTagsWithSignatures(t *testing.T) { + Convey("SyncLegacyCosignTags controls whether legacy cosign signature tags (.sig) are synced", t, func() { + sctlr, srcBaseURL, _, _ := makeUpstreamServer(t, false, false) + scm := test.NewControllerManager(sctlr) + scm.StartAndWait(sctlr.Config.HTTP.Port) + defer scm.StopServer() + + // Push image and sign it (adds legacy .sig tag on source) + var digest godigest.Digest + So(func() { digest = pushRepo(srcBaseURL, testSignedImage) }, ShouldNotPanic) + srcPort := getPortFromBaseURL(srcBaseURL) + cwd, err := os.Getwd() + So(err, ShouldBeNil) + defer func() { _ = os.Chdir(cwd) }() + tdir := t.TempDir() + _ = os.Chdir(tdir) + generateKeyPairs(tdir) + So(func() { signImage(tdir, srcPort, testSignedImage, digest) }, ShouldNotPanic) + + legacySigTag := digest.Algorithm().String() + "-" + digest.Encoded() + ".sig" + imageDigestStr := digest.String() + tlsVerify := false + regex := ".*" + semver := false + + Convey("With SyncLegacyCosignTags true, legacy signature tag is synced", func() { + syncLegacyTrue := true + syncRegistryConfig := syncconf.RegistryConfig{ + Content: []syncconf.Content{ + {Prefix: "**", Tags: &syncconf.Tags{Regex: ®ex, Semver: &semver}}, + }, + URLs: []string{srcBaseURL}, + TLSVerify: &tlsVerify, + OnDemand: true, + SyncLegacyCosignTags: &syncLegacyTrue, + } + defaultVal := true + syncConfig := &syncconf.Config{ + Enable: &defaultVal, + Registries: []syncconf.RegistryConfig{syncRegistryConfig}, + } + dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig) + dcm := test.NewControllerManager(dctlr) + dcm.StartAndWait(dctlr.Config.HTTP.Port) + defer dcm.StopServer() + + // Trigger image sync + resp, err := destClient.R().Get(destBaseURL + "/v2/" + testSignedImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // Trigger referrers sync (pulls legacy .sig when SyncLegacyCosignTags is true) + resp, err = destClient.R().Get(destBaseURL + "/v2/" + testSignedImage + "/referrers/" + imageDigestStr) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Verify via storage (avoid GET manifest for legacy tag, which can trigger on-demand sync) + indexPath := path.Join(destDir, testSignedImage, "index.json") + found := false + + for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(250 * time.Millisecond) { + indexBuf, err := os.ReadFile(indexPath) + if err != nil { + continue + } + + var destIndex ispec.Index + if json.Unmarshal(indexBuf, &destIndex) != nil { + continue + } + + for _, desc := range destIndex.Manifests { + if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacySigTag { + found = true + + break + } + } + + if found { + break + } + } + + if !found { + t.Fatalf("legacy signature tag %q should be present on destination index", legacySigTag) + } + }) + + Convey("With SyncLegacyCosignTags false, legacy signature tag is not synced", func() { + syncLegacyFalse := false + pollInterval, _ := time.ParseDuration("5s") + syncRegistryConfig := syncconf.RegistryConfig{ + Content: []syncconf.Content{ + {Prefix: "**", Tags: &syncconf.Tags{Regex: ®ex, Semver: &semver}}, + }, + URLs: []string{srcBaseURL}, + TLSVerify: &tlsVerify, + OnDemand: false, + PollInterval: pollInterval, + SyncLegacyCosignTags: &syncLegacyFalse, + } + defaultVal := true + syncConfig := &syncconf.Config{ + Enable: &defaultVal, + Registries: []syncconf.RegistryConfig{syncRegistryConfig}, + } + dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig) + dcm := test.NewControllerManager(dctlr) + dcm.StartAndWait(dctlr.Config.HTTP.Port) + defer dcm.StopServer() + + // Wait for periodic sync to complete for this repo (do not call GET manifest for legacy tag to avoid on-demand sync) + found, err := test.ReadLogFileAndSearchString(dctlr.Config.Log.Output, "finished syncing repo", 30*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) + // Poll until signed-repo exists on destination (periodic sync may sync multiple repos) + var resp *resty.Response + for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(500 * time.Millisecond) { + resp, err = destClient.R().Get(destBaseURL + "/v2/" + testSignedImage + "/tags/list") + if err == nil && resp != nil && resp.StatusCode() == http.StatusOK { + break + } + } + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Verify via tags/list: legacy signature tag must not be present + var destTags TagsList + So(json.Unmarshal(resp.Body(), &destTags), ShouldBeNil) + So(destTags.Tags, ShouldNotContain, legacySigTag) + + // Verify via storage: index.json must not reference the legacy signature tag + indexPath := path.Join(destDir, testSignedImage, "index.json") + indexBuf, err := os.ReadFile(indexPath) + So(err, ShouldBeNil) + + var destIndex ispec.Index + So(json.Unmarshal(indexBuf, &destIndex), ShouldBeNil) + + for _, desc := range destIndex.Manifests { + if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacySigTag { + t.Fatalf("legacy signature tag %q should not be in destination index", legacySigTag) + } + } + }) + }) +} + func TestOnDemandRetryGoroutine(t *testing.T) { Convey("Verify ondemand sync retries in background on error", t, func() { srcPort := test.GetFreePort() @@ -7501,6 +7799,343 @@ func TestECRCredentialsHelper(t *testing.T) { }) } +// TestOnDemandReferrerSyncFlags verifies SyncLegacyCosignTags and on-demand +// non-recursive referrer sync in a full upstream/downstream server setup. +// +// Topology pushed to the upstream: +// +// testImage:testImageTag (base image) +// ├── oci-referrer (OCI-spec referrer — attached via subject field) +// └── sha256-.sig (legacy cosign-style digest-encoded tag) +// +// With SyncLegacyCosignTags=false, referrers from the referrers API are synced, +// but the legacy .sig tag is not; the .sig tag must not appear on the downstream. +// On-demand SyncReferrers never recurses (only direct referrers are synced); +// recursive referrer sync is done only in periodic sync. +func TestOnDemandReferrerSyncFlags(t *testing.T) { + Convey("SyncLegacyCosignTags=false syncs OCI referrers but not digest-tag referrers", t, func() { + sctlr, srcBaseURL, _, _ := makeUpstreamServer(t, false, false) + + scm := test.NewControllerManager(sctlr) + scm.StartAndWait(sctlr.Config.HTTP.Port) + + defer scm.StopServer() + + // ── 1. Fetch the subject digest ────────────────────────────────────── + resp, err := resty.R().Get(srcBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + subjectDigest := resp.Header().Get("Docker-Content-Digest") + subjectSize := len(resp.Body()) + + // ── 2. Push an OCI referrer (attached via subject field) ───────────── + _ = pushBlob(srcBaseURL, testImage, ispec.DescriptorEmptyJSON.Data) + + ociRef := ispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Subject: &ispec.Descriptor{ + MediaType: ispec.MediaTypeImageManifest, + Digest: godigest.Digest(subjectDigest), + Size: int64(subjectSize), + }, + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }, + Layers: []ispec.Descriptor{{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }}, + MediaType: ispec.MediaTypeImageManifest, + } + + ociRefBlob, err := json.Marshal(ociRef) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-type", ispec.MediaTypeImageManifest). + SetBody(ociRefBlob). + Put(srcBaseURL + "/v2/" + testImage + "/manifests/oci.ref") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // ── 3. Push a legacy cosign digest-tag referrer (sha256-.sig) ─── + sigTag := fmt.Sprintf("%s-%s.sig", + godigest.Digest(subjectDigest).Algorithm(), + godigest.Digest(subjectDigest).Encoded()) + + _ = pushBlob(srcBaseURL, testImage, ispec.DescriptorEmptyJSON.Data) + + sigManifest := ispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Config: ispec.Descriptor{ + MediaType: "application/vnd.dev.cosign.simplesigning.v1+json", + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }, + Layers: []ispec.Descriptor{{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }}, + MediaType: ispec.MediaTypeImageManifest, + } + + sigBlob, err := json.Marshal(sigManifest) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-type", ispec.MediaTypeImageManifest). + SetBody(sigBlob). + Put(srcBaseURL + "/v2/" + testImage + "/manifests/" + sigTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // Verify upstream has both the OCI referrer and the sig tag. + resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var upstreamIdx ispec.Index + + So(json.Unmarshal(resp.Body(), &upstreamIdx), ShouldBeNil) + So(len(upstreamIdx.Manifests), ShouldEqual, 1) // only OCI referrer in referrers API + + resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.String(), ShouldContainSubstring, sigTag) + + // ── 4. Start downstream with SyncLegacyCosignTags=false ────────────── + var tlsVerify bool + syncLegacyFalse := false + + syncRegistryConfig := syncconf.RegistryConfig{ + Content: []syncconf.Content{{ + Prefix: testImage, + }}, + URLs: []string{srcBaseURL}, + TLSVerify: &tlsVerify, + OnDemand: true, + SyncLegacyCosignTags: &syncLegacyFalse, + } + + defaultVal := true + syncConfig := &syncconf.Config{ + Enable: &defaultVal, + Registries: []syncconf.RegistryConfig{syncRegistryConfig}, + } + + dctlr, destBaseURL, _, _ := makeDownstreamServer(t, false, syncConfig) + + dcm := test.NewControllerManager(dctlr) + dcm.StartAndWait(dctlr.Config.HTTP.Port) + + defer dcm.StopServer() + + // ── 5. Trigger on-demand sync of the base image ────────────────────── + resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // ── 6. Trigger on-demand sync of referrers ─────────────────────────── + resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var downstreamIdx ispec.Index + + So(json.Unmarshal(resp.Body(), &downstreamIdx), ShouldBeNil) + // OCI referrer must be present. + So(len(downstreamIdx.Manifests), ShouldEqual, 1) + + // ── 7. The sig tag must NOT have been synced ────────────────────────── + resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/tags/list") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.String(), ShouldNotContainSubstring, sigTag) + }) + + Convey("On-demand SyncReferrers syncs only direct referrers (no recursion)", t, func() { + sctlr, srcBaseURL, _, _ := makeUpstreamServer(t, false, false) + + scm := test.NewControllerManager(sctlr) + scm.StartAndWait(sctlr.Config.HTTP.Port) + + defer scm.StopServer() + + // ── 1. Fetch the subject digest ────────────────────────────────────── + resp, err := resty.R().Get(srcBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + subjectDigest := resp.Header().Get("Docker-Content-Digest") + subjectSize := len(resp.Body()) + + // ── 2. Push a direct OCI referrer ──────────────────────────────────── + _ = pushBlob(srcBaseURL, testImage, ispec.DescriptorEmptyJSON.Data) + + directRef := ispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Subject: &ispec.Descriptor{ + MediaType: ispec.MediaTypeImageManifest, + Digest: godigest.Digest(subjectDigest), + Size: int64(subjectSize), + }, + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }, + Layers: []ispec.Descriptor{{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }}, + MediaType: ispec.MediaTypeImageManifest, + Annotations: map[string]string{"level": "direct"}, + } + + directRefBlob, err := json.Marshal(directRef) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-type", ispec.MediaTypeImageManifest). + SetBody(directRefBlob). + Put(srcBaseURL + "/v2/" + testImage + "/manifests/direct.ref") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + directRefDigest := resp.Header().Get("Docker-Content-Digest") + + // ── 3. Push a nested referrer (referrer-of-referrer) ───────────────── + nestedRef := ispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Subject: &ispec.Descriptor{ + MediaType: ispec.MediaTypeImageManifest, + Digest: godigest.Digest(directRefDigest), + Size: int64(len(directRefBlob)), + }, + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }, + Layers: []ispec.Descriptor{{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: 2, + }}, + MediaType: ispec.MediaTypeImageManifest, + Annotations: map[string]string{"level": "nested"}, + } + + nestedRefBlob, err := json.Marshal(nestedRef) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-type", ispec.MediaTypeImageManifest). + SetBody(nestedRefBlob). + Put(srcBaseURL + "/v2/" + testImage + "/manifests/nested.ref") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + _ = resp.Header().Get("Docker-Content-Digest") + + // Verify upstream has the referrer chain in place. + resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var upstreamDirect ispec.Index + + So(json.Unmarshal(resp.Body(), &upstreamDirect), ShouldBeNil) + So(len(upstreamDirect.Manifests), ShouldEqual, 1) + + resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/referrers/" + directRefDigest) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var upstreamNested ispec.Index + + So(json.Unmarshal(resp.Body(), &upstreamNested), ShouldBeNil) + So(len(upstreamNested.Manifests), ShouldEqual, 1) + + // ── 4. Start downstream with on-demand (referrer sync never recurses) ─ + var tlsVerify bool + syncLegacyFalse := false + + syncRegistryConfig := syncconf.RegistryConfig{ + Content: []syncconf.Content{{ + Prefix: testImage, + }}, + URLs: []string{srcBaseURL}, + TLSVerify: &tlsVerify, + OnDemand: true, + SyncLegacyCosignTags: &syncLegacyFalse, // avoid legacy tag listing + } + + defaultVal := true + syncConfig := &syncconf.Config{ + Enable: &defaultVal, + Registries: []syncconf.RegistryConfig{syncRegistryConfig}, + } + + dctlr, destBaseURL, _, _ := makeDownstreamServer(t, false, syncConfig) + + dcm := test.NewControllerManager(dctlr) + dcm.StartAndWait(dctlr.Config.HTTP.Port) + + defer dcm.StopServer() + + // ── 5. Trigger on-demand sync of the base image ────────────────────── + resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // ── 6. Trigger on-demand sync of referrers for the base image ──────── + resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var downstreamDirect ispec.Index + + So(json.Unmarshal(resp.Body(), &downstreamDirect), ShouldBeNil) + // The direct referrer must be synced. + So(len(downstreamDirect.Manifests), ShouldEqual, 1) + + // ── 7. Verify the nested referrer is NOT synced ────────────────────── + // On-demand SyncReferrers never recurses; only direct referrers of the + // requested subject are synced. So syncing referrers for the base image + // did not recursively sync referrers of the direct referrer. + // + // We MUST NOT query the Referrers API on the downstream here, because + // that would trigger a second on-demand sync for the direct referrer. + // Instead, we check the metaDB to see if any referrers are recorded for + // the directRefDigest. + refs, err := dctlr.MetaDB.GetReferrersInfo(testImage, godigest.Digest(directRefDigest), nil) + So(err, ShouldBeNil) + // If recursive sync had happened, this would be 1. + So(len(refs), ShouldEqual, 0) + + // ── 8. Verify that nested referrer is synced when explicitly requested + resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + directRefDigest) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var downstreamNested ispec.Index + + So(json.Unmarshal(resp.Body(), &downstreamNested), ShouldBeNil) + So(len(downstreamNested.Manifests), ShouldEqual, 1) + refs, err = dctlr.MetaDB.GetReferrersInfo(testImage, godigest.Digest(directRefDigest), nil) + So(err, ShouldBeNil) + So(len(refs), ShouldEqual, 1) + }) +} + func generateKeyPairs(tdir string) { // generate a keypair os.Setenv("COSIGN_PASSWORD", "")