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/<name>/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 <milosthegajdos@gmail.com>
This commit is contained in:
Milos Gajdos
2026-06-14 17:20:12 -07:00
parent af1b4b4e9a
commit d1dbbb196c
2 changed files with 63 additions and 1 deletions

View File

@@ -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, "/")
}

View File

@@ -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)