detached 52a7ef7

Merge pull request #51 from bnema/fix/rollback-tag-list

fix(registry): exclude digest references from tag list
commit
52a7ef79fd6cf0b48ac349c094866db06f267e0a
author
bnema · · 2026-02-03 20:04 UTC
committer
GitHub · · 2026-02-03 20:04 UTC
merge of
43db1bb + ac3d4d7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +146 -2
M internal/adapters/out/filesystem/manifest.go +19 -0
@@ -303,12 +303,29 @@ func (s *ManifestStorage) deleteManifestContentType(name, reference string) erro
303303
304304 // Tags management helpers
305305
306+// filterOutDigests removes digest entries from a tag list.
307+func filterOutDigests(tags []string) []string {
308+ filtered := make([]string, 0, len(tags))
309+ for _, tag := range tags {
310+ if !validation.IsDigest(tag) {
311+ filtered = append(filtered, tag)
312+ }
313+ }
314+ return filtered
315+}
316+
306317 func (s *ManifestStorage) updateTagsList(name, reference string) error {
318+ if validation.IsDigest(reference) {
319+ return nil
320+ }
321+
307322 tags, err := s.ListTags(name)
308323 if err != nil {
309324 return err
310325 }
311326
327+ tags = filterOutDigests(tags)
328+
312329 // Add tag if not already present
313330 for _, tag := range tags {
314331 if tag == reference {
@@ -326,6 +343,8 @@ func (s *ManifestStorage) removeFromTagsList(name, reference string) error {
326343 return err
327344 }
328345
346+ tags = filterOutDigests(tags)
347+
329348 // Remove tag if present
330349 var newTags []string
331350 for _, tag := range tags {
@@ -303,12 +303,29 @@ func (s *ManifestStorage) deleteManifestContentType(name, reference string) erro
303 303
304 // Tags management helpers304 // Tags management helpers
305 305
306+// filterOutDigests removes digest entries from a tag list.
307+func filterOutDigests(tags []string) []string {
308+ filtered := make([]string, 0, len(tags))
309+ for _, tag := range tags {
310+ if !validation.IsDigest(tag) {
311+ filtered = append(filtered, tag)
312+ }
313+ }
314+ return filtered
315+}
316+
306 func (s *ManifestStorage) updateTagsList(name, reference string) error {317 func (s *ManifestStorage) updateTagsList(name, reference string) error {
318+ if validation.IsDigest(reference) {
319+ return nil
320+ }
321+
307 tags, err := s.ListTags(name)322 tags, err := s.ListTags(name)
308 if err != nil {323 if err != nil {
309 return err324 return err
310 }325 }
311 326
327+ tags = filterOutDigests(tags)
328+
312 // Add tag if not already present329 // Add tag if not already present
313 for _, tag := range tags {330 for _, tag := range tags {
314 if tag == reference {331 if tag == reference {
@@ -326,6 +343,8 @@ func (s *ManifestStorage) removeFromTagsList(name, reference string) error {
326 return err343 return err
327 }344 }
328 345
346+ tags = filterOutDigests(tags)
347+
329 // Remove tag if present348 // Remove tag if present
330 var newTags []string349 var newTags []string
331 for _, tag := range tags {350 for _, tag := range tags {
M internal/adapters/out/filesystem/manifest_test.go +105 -0
@@ -173,6 +173,25 @@ func TestManifestStorage_ListTags_Empty(t *testing.T) {
173173 assert.Empty(t, tags)
174174 }
175175
176+func TestManifestStorage_ListTags_SkipsDigestReference(t *testing.T) {
177+ tmpDir := t.TempDir()
178+ log := testLogger()
179+
180+ storage, err := NewManifestStorage(tmpDir, log)
181+ require.NoError(t, err)
182+
183+ manifestData := []byte(`{"schemaVersion": 2}`)
184+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
185+
186+ err = storage.PutManifest("myapp", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", contentType, manifestData)
187+ require.NoError(t, err)
188+
189+ tags, err := storage.ListTags("myapp")
190+ require.NoError(t, err)
191+
192+ assert.Empty(t, tags)
193+}
194+
176195 func TestManifestStorage_ListTags_AfterDelete(t *testing.T) {
177196 tmpDir := t.TempDir()
178197 log := testLogger()
@@ -438,3 +457,89 @@ func TestManifestStorage_CompleteWorkflow(t *testing.T) {
438457 require.NoError(t, err)
439458 assert.Contains(t, repos, "myorg/myapp")
440459 }
460+
461+func TestManifestStorage_UpdateTagsList_CleansLegacyDigestEntries(t *testing.T) {
462+ tmpDir := t.TempDir()
463+ log := testLogger()
464+
465+ storage, err := NewManifestStorage(tmpDir, log)
466+ require.NoError(t, err)
467+
468+ manifestData := []byte(`{"schemaVersion": 2}`)
469+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
470+
471+ // Create initial tags
472+ err = storage.PutManifest("myapp", "v1.0", contentType, manifestData)
473+ require.NoError(t, err)
474+ err = storage.PutManifest("myapp", "v2.0", contentType, manifestData)
475+ require.NoError(t, err)
476+
477+ // Manually inject digest entries into tags.json to simulate legacy data
478+ tagsPath := filepath.Join(tmpDir, "repositories", "myapp", "tags.json")
479+
480+ // Write the contaminated tags list directly
481+ err = os.WriteFile(tagsPath, []byte(`["v1.0","v2.0","sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]`), 0600)
482+ require.NoError(t, err)
483+
484+ // Verify tags.json contains digest entries
485+ tagsBefore, err := storage.ListTags("myapp")
486+ require.NoError(t, err)
487+ require.Len(t, tagsBefore, 4, "Should have 4 entries (2 tags + 2 digests)")
488+
489+ // Add a new tag - this should trigger cleanup of legacy digest entries
490+ err = storage.PutManifest("myapp", "v3.0", contentType, manifestData)
491+ require.NoError(t, err)
492+
493+ // Verify that digest entries were cleaned up
494+ tagsAfter, err := storage.ListTags("myapp")
495+ require.NoError(t, err)
496+ assert.Len(t, tagsAfter, 3, "Should have 3 entries (only tags, digests removed)")
497+ assert.Contains(t, tagsAfter, "v1.0")
498+ assert.Contains(t, tagsAfter, "v2.0")
499+ assert.Contains(t, tagsAfter, "v3.0")
500+ assert.NotContains(t, tagsAfter, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
501+ assert.NotContains(t, tagsAfter, "sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
502+}
503+
504+func TestManifestStorage_RemoveFromTagsList_CleansLegacyDigestEntries(t *testing.T) {
505+ tmpDir := t.TempDir()
506+ log := testLogger()
507+
508+ storage, err := NewManifestStorage(tmpDir, log)
509+ require.NoError(t, err)
510+
511+ manifestData := []byte(`{"schemaVersion": 2}`)
512+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
513+
514+ // Create initial tags
515+ err = storage.PutManifest("myapp", "v1.0", contentType, manifestData)
516+ require.NoError(t, err)
517+ err = storage.PutManifest("myapp", "v2.0", contentType, manifestData)
518+ require.NoError(t, err)
519+ err = storage.PutManifest("myapp", "v3.0", contentType, manifestData)
520+ require.NoError(t, err)
521+
522+ // Manually inject digest entries into tags.json to simulate legacy data
523+ tagsPath := filepath.Join(tmpDir, "repositories", "myapp", "tags.json")
524+ err = os.WriteFile(tagsPath, []byte(`["v1.0","v2.0","v3.0","sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]`), 0600)
525+ require.NoError(t, err)
526+
527+ // Verify tags.json contains digest entries
528+ tagsBefore, err := storage.ListTags("myapp")
529+ require.NoError(t, err)
530+ require.Len(t, tagsBefore, 5, "Should have 5 entries (3 tags + 2 digests)")
531+
532+ // Delete a tag - this should trigger cleanup of legacy digest entries
533+ err = storage.DeleteManifest("myapp", "v2.0")
534+ require.NoError(t, err)
535+
536+ // Verify that digest entries were cleaned up and v2.0 was removed
537+ tagsAfter, err := storage.ListTags("myapp")
538+ require.NoError(t, err)
539+ assert.Len(t, tagsAfter, 2, "Should have 2 entries (only remaining tags, digests removed)")
540+ assert.Contains(t, tagsAfter, "v1.0")
541+ assert.Contains(t, tagsAfter, "v3.0")
542+ assert.NotContains(t, tagsAfter, "v2.0")
543+ assert.NotContains(t, tagsAfter, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
544+ assert.NotContains(t, tagsAfter, "sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
545+}
@@ -173,6 +173,25 @@ func TestManifestStorage_ListTags_Empty(t *testing.T) {
173 assert.Empty(t, tags)173 assert.Empty(t, tags)
174 }174 }
175 175
176+func TestManifestStorage_ListTags_SkipsDigestReference(t *testing.T) {
177+ tmpDir := t.TempDir()
178+ log := testLogger()
179+
180+ storage, err := NewManifestStorage(tmpDir, log)
181+ require.NoError(t, err)
182+
183+ manifestData := []byte(`{"schemaVersion": 2}`)
184+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
185+
186+ err = storage.PutManifest("myapp", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", contentType, manifestData)
187+ require.NoError(t, err)
188+
189+ tags, err := storage.ListTags("myapp")
190+ require.NoError(t, err)
191+
192+ assert.Empty(t, tags)
193+}
194+
176 func TestManifestStorage_ListTags_AfterDelete(t *testing.T) {195 func TestManifestStorage_ListTags_AfterDelete(t *testing.T) {
177 tmpDir := t.TempDir()196 tmpDir := t.TempDir()
178 log := testLogger()197 log := testLogger()
@@ -438,3 +457,89 @@ func TestManifestStorage_CompleteWorkflow(t *testing.T) {
438 require.NoError(t, err)457 require.NoError(t, err)
439 assert.Contains(t, repos, "myorg/myapp")458 assert.Contains(t, repos, "myorg/myapp")
440 }459 }
460+
461+func TestManifestStorage_UpdateTagsList_CleansLegacyDigestEntries(t *testing.T) {
462+ tmpDir := t.TempDir()
463+ log := testLogger()
464+
465+ storage, err := NewManifestStorage(tmpDir, log)
466+ require.NoError(t, err)
467+
468+ manifestData := []byte(`{"schemaVersion": 2}`)
469+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
470+
471+ // Create initial tags
472+ err = storage.PutManifest("myapp", "v1.0", contentType, manifestData)
473+ require.NoError(t, err)
474+ err = storage.PutManifest("myapp", "v2.0", contentType, manifestData)
475+ require.NoError(t, err)
476+
477+ // Manually inject digest entries into tags.json to simulate legacy data
478+ tagsPath := filepath.Join(tmpDir, "repositories", "myapp", "tags.json")
479+
480+ // Write the contaminated tags list directly
481+ err = os.WriteFile(tagsPath, []byte(`["v1.0","v2.0","sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]`), 0600)
482+ require.NoError(t, err)
483+
484+ // Verify tags.json contains digest entries
485+ tagsBefore, err := storage.ListTags("myapp")
486+ require.NoError(t, err)
487+ require.Len(t, tagsBefore, 4, "Should have 4 entries (2 tags + 2 digests)")
488+
489+ // Add a new tag - this should trigger cleanup of legacy digest entries
490+ err = storage.PutManifest("myapp", "v3.0", contentType, manifestData)
491+ require.NoError(t, err)
492+
493+ // Verify that digest entries were cleaned up
494+ tagsAfter, err := storage.ListTags("myapp")
495+ require.NoError(t, err)
496+ assert.Len(t, tagsAfter, 3, "Should have 3 entries (only tags, digests removed)")
497+ assert.Contains(t, tagsAfter, "v1.0")
498+ assert.Contains(t, tagsAfter, "v2.0")
499+ assert.Contains(t, tagsAfter, "v3.0")
500+ assert.NotContains(t, tagsAfter, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
501+ assert.NotContains(t, tagsAfter, "sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
502+}
503+
504+func TestManifestStorage_RemoveFromTagsList_CleansLegacyDigestEntries(t *testing.T) {
505+ tmpDir := t.TempDir()
506+ log := testLogger()
507+
508+ storage, err := NewManifestStorage(tmpDir, log)
509+ require.NoError(t, err)
510+
511+ manifestData := []byte(`{"schemaVersion": 2}`)
512+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
513+
514+ // Create initial tags
515+ err = storage.PutManifest("myapp", "v1.0", contentType, manifestData)
516+ require.NoError(t, err)
517+ err = storage.PutManifest("myapp", "v2.0", contentType, manifestData)
518+ require.NoError(t, err)
519+ err = storage.PutManifest("myapp", "v3.0", contentType, manifestData)
520+ require.NoError(t, err)
521+
522+ // Manually inject digest entries into tags.json to simulate legacy data
523+ tagsPath := filepath.Join(tmpDir, "repositories", "myapp", "tags.json")
524+ err = os.WriteFile(tagsPath, []byte(`["v1.0","v2.0","v3.0","sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]`), 0600)
525+ require.NoError(t, err)
526+
527+ // Verify tags.json contains digest entries
528+ tagsBefore, err := storage.ListTags("myapp")
529+ require.NoError(t, err)
530+ require.Len(t, tagsBefore, 5, "Should have 5 entries (3 tags + 2 digests)")
531+
532+ // Delete a tag - this should trigger cleanup of legacy digest entries
533+ err = storage.DeleteManifest("myapp", "v2.0")
534+ require.NoError(t, err)
535+
536+ // Verify that digest entries were cleaned up and v2.0 was removed
537+ tagsAfter, err := storage.ListTags("myapp")
538+ require.NoError(t, err)
539+ assert.Len(t, tagsAfter, 2, "Should have 2 entries (only remaining tags, digests removed)")
540+ assert.Contains(t, tagsAfter, "v1.0")
541+ assert.Contains(t, tagsAfter, "v3.0")
542+ assert.NotContains(t, tagsAfter, "v2.0")
543+ assert.NotContains(t, tagsAfter, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
544+ assert.NotContains(t, tagsAfter, "sha256:a6b1c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
545+}
M internal/usecase/registry/service.go +10 -1
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/gordon/internal/boundaries/out"
1313 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/pkg/validation"
1415 )
1516
1617 // Service implements the RegistryService interface.
@@ -250,7 +251,15 @@ func (s *Service) ListTags(ctx context.Context, name string) ([]string, error) {
250251 return nil, log.WrapErr(err, "failed to list tags")
251252 }
252253
253- return tags, nil
254+ filtered := make([]string, 0, len(tags))
255+ for _, tag := range tags {
256+ if validation.IsDigest(tag) {
257+ continue
258+ }
259+ filtered = append(filtered, tag)
260+ }
261+
262+ return filtered, nil
254263 }
255264
256265 // ListRepositories returns all repository names.
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/gordon/internal/boundaries/out"12 "github.com/bnema/gordon/internal/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/pkg/validation"
14 )15 )
15 16
16 // Service implements the RegistryService interface.17 // Service implements the RegistryService interface.
@@ -250,7 +251,15 @@ func (s *Service) ListTags(ctx context.Context, name string) ([]string, error) {
250 return nil, log.WrapErr(err, "failed to list tags")251 return nil, log.WrapErr(err, "failed to list tags")
251 }252 }
252 253
253- return tags, nil254+ filtered := make([]string, 0, len(tags))
255+ for _, tag := range tags {
256+ if validation.IsDigest(tag) {
257+ continue
258+ }
259+ filtered = append(filtered, tag)
260+ }
261+
262+ return filtered, nil
254 }263 }
255 264
256 // ListRepositories returns all repository names.265 // ListRepositories returns all repository names.
M internal/usecase/registry/service_test.go +6 -1
@@ -375,7 +375,12 @@ func TestService_ListTags_Success(t *testing.T) {
375375 svc := NewService(blobStorage, manifestStorage, eventBus)
376376 ctx := testContext()
377377
378- manifestStorage.EXPECT().ListTags("myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
378+ manifestStorage.EXPECT().ListTags("myapp").Return([]string{
379+ "latest",
380+ "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
381+ "v1.0",
382+ "v2.0",
383+ }, nil)
379384
380385 tags, err := svc.ListTags(ctx, "myapp")
381386
@@ -375,7 +375,12 @@ func TestService_ListTags_Success(t *testing.T) {
375 svc := NewService(blobStorage, manifestStorage, eventBus)375 svc := NewService(blobStorage, manifestStorage, eventBus)
376 ctx := testContext()376 ctx := testContext()
377 377
378- manifestStorage.EXPECT().ListTags("myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)378+ manifestStorage.EXPECT().ListTags("myapp").Return([]string{
379+ "latest",
380+ "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
381+ "v1.0",
382+ "v2.0",
383+ }, nil)
379 384
380 tags, err := svc.ListTags(ctx, "myapp")385 tags, err := svc.ListTags(ctx, "myapp")
381 386
M pkg/validation/path.go +6 -0
@@ -102,6 +102,12 @@ func ValidateDigest(digest string) error {
102102 return nil
103103 }
104104
105+// IsDigest checks if a string is a valid Docker content digest.
106+// Returns true if the string is a valid digest format.
107+func IsDigest(digest string) bool {
108+ return ValidateDigest(digest) == nil
109+}
110+
105111 // ValidateUUID validates a blob upload UUID.
106112 // UUIDs are server-generated but still validated for safety.
107113 func ValidateUUID(uuid string) error {
@@ -102,6 +102,12 @@ func ValidateDigest(digest string) error {
102 return nil102 return nil
103 }103 }
104 104
105+// IsDigest checks if a string is a valid Docker content digest.
106+// Returns true if the string is a valid digest format.
107+func IsDigest(digest string) bool {
108+ return ValidateDigest(digest) == nil
109+}
110+
105 // ValidateUUID validates a blob upload UUID.111 // ValidateUUID validates a blob upload UUID.
106 // UUIDs are server-generated but still validated for safety.112 // UUIDs are server-generated but still validated for safety.
107 func ValidateUUID(uuid string) error {113 func ValidateUUID(uuid string) error {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close