From d1dbbb196cbc27bb93497874a46ebca9d4f37395 Mon Sep 17 00:00:00 2001 From: Milos Gajdos Date: Sun, 14 Jun 2026 17:20:12 -0700 Subject: [PATCH] fix(s3): tags/list drops tags that are a lexical prefix of another tag The S3 driver's Walk used a bare strings.HasPrefix to decide whether a walkInfo was under the last ErrSkipDir directory. Without a trailing "/" on the parent, a sibling whose name starts with the skipped directory's name (e.g. "0.1.20" after "0.1.2") falsely matched and was skipped, so its tag dir was never emitted to handleTag and the tag was omitted from /v2//tags/list. Replace the prefix check with isSubpath, which appends "/" to the parent so only true descendants match. Add a unit test pinning the sibling-with-lexical-prefix case. The bug is S3-only because the filesystem and inmemory drivers list directories recursively via WalkFallback and don't use this skip mechanism. It surfaced in 3.1.0 when the tags handler switched from tagService.All() (driver List with delimiter) to tagService.List() (driver Walk) for pagination support. Fixes #4891 Signed-off-by: Milos Gajdos --- registry/storage/driver/s3-aws/s3.go | 15 ++++++- registry/storage/driver/s3-aws/s3_test.go | 49 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/registry/storage/driver/s3-aws/s3.go b/registry/storage/driver/s3-aws/s3.go index 161b94fd3..29af8b070 100644 --- a/registry/storage/driver/s3-aws/s3.go +++ b/registry/storage/driver/s3-aws/s3.go @@ -1178,7 +1178,7 @@ func (d *driver) doWalk(parentCtx context.Context, objectCount *int64, from, sta for _, walkInfo := range walkInfos { // skip any results under the last skip directory - if prevSkipDir != "" && strings.HasPrefix(walkInfo.Path(), prevSkipDir) { + if isSubpath(walkInfo.Path(), prevSkipDir) { continue } @@ -1249,6 +1249,19 @@ func directoryDiff(prev, current string) []string { return paths } +func isSubpath(path, parent string) bool { + if parent == "" { + return false + } + if path == parent { + return true + } + if parent == "/" { + return strings.HasPrefix(path, "/") + } + return strings.HasPrefix(path, parent+"/") +} + func (d *driver) s3Path(path string) string { return strings.TrimLeft(strings.TrimRight(d.RootDirectory, "/")+path, "/") } diff --git a/registry/storage/driver/s3-aws/s3_test.go b/registry/storage/driver/s3-aws/s3_test.go index a4b303d3f..e5d6c8d5e 100644 --- a/registry/storage/driver/s3-aws/s3_test.go +++ b/registry/storage/driver/s3-aws/s3_test.go @@ -292,6 +292,55 @@ func TestGetS3LogLevelFromParam(t *testing.T) { } } +func TestIsSubpath(t *testing.T) { + tests := []struct { + name string + path string + parent string + expected bool + }{ + { + name: "empty parent", + path: "/tags/1.2", + parent: "", + expected: false, + }, + { + name: "same path", + path: "/tags/1.2", + parent: "/tags/1.2", + expected: true, + }, + { + name: "descendant path", + path: "/tags/1.2/current/link", + parent: "/tags/1.2", + expected: true, + }, + { + name: "sibling with lexical prefix", + path: "/tags/1.20/current/link", + parent: "/tags/1.2", + expected: false, + }, + { + name: "root parent", + path: "/tags/1.2", + parent: "/", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := isSubpath(tt.path, tt.parent) + if actual != tt.expected { + t.Fatalf("isSubpath(%q, %q) = %t, want %t", tt.path, tt.parent, actual, tt.expected) + } + }) + } +} + func TestEmptyRootList(t *testing.T) { skipCheck(t)