From 00648d9a02d29e128bf9bb48eda1ac5931cb068e Mon Sep 17 00:00:00 2001 From: ankit090701 Date: Sat, 1 Aug 2026 19:22:10 +0530 Subject: [PATCH] fix: image ls with a bare repository name should match all tags `nerdctl image ls myapp`, where myapp is a bare repository name (no tag or digest), returned nothing unless myapp happened to have a `:latest` tag - unlike `docker image ls myapp`, which lists every tag of the repository. referenceutil.Parse normalizes a bare repository name by unconditionally applying distribution/reference's TagNameOnly, which appends an implicit ":latest" tag. listOptions then built an exact-match filter from that normalized reference (`name==docker.io/library/myapp:latest`), so any image tagged anything other than "latest" was silently excluded. Extract the filter-construction logic into a small nameFilterFor helper. When the argument named an explicit tag or digest, keep matching it exactly. When it was a bare repository name, build a `name~=^:` regex filter instead (escaping the repository name with regexp.QuoteMeta), so it matches any tag under that repository. This mirrors the `~=` regex-filter pattern already used elsewhere in the codebase for similar purposes, e.g. pkg/idutil/imagewalker/imagewalker.go and pkg/idutil/containerwalker/containerwalker.go. Added TestNameFilterFor covering a bare repository name (now matches any tag), an explicit tag, an explicit ":latest" tag, a digest, and a bare name with a domain that needs regex-escaping. Verified this test fails without the fix, reproducing the exact reported behaviour (name==docker.io/library/myapp:latest instead of a repo-wide match). Fixes #5113 Signed-off-by: ankit090701 --- cmd/nerdctl/image/image_list.go | 20 +++++++++++- cmd/nerdctl/image/image_list_test.go | 48 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/cmd/nerdctl/image/image_list.go b/cmd/nerdctl/image/image_list.go index a42337fba23..7776e0217df 100644 --- a/cmd/nerdctl/image/image_list.go +++ b/cmd/nerdctl/image/image_list.go @@ -18,6 +18,7 @@ package image import ( "fmt" + "regexp" "github.com/spf13/cobra" @@ -81,7 +82,7 @@ func listOptions(cmd *cobra.Command, args []string) (*types.ImageListOptions, er if err != nil { return nil, err } - filters = []string{fmt.Sprintf("name==%s", parsedReference)} + filters = nameFilterFor(parsedReference) } quiet, err := cmd.Flags().GetBool("quiet") if err != nil { @@ -125,6 +126,23 @@ func listOptions(cmd *cobra.Command, args []string) (*types.ImageListOptions, er } +// nameFilterFor builds the containerd image-service filter(s) matching the +// argument to `nerdctl image ls [REPOSITORY[:TAG]]`. +// +// If the argument named an explicit tag or digest, it's matched exactly. +// Otherwise the argument was a bare repository name: referenceutil.Parse +// normalizes that to an implicit ":latest" tag (matching how most other +// reference-consuming commands resolve a bare name), but for listing +// purposes that would incorrectly hide every other tag of the repository - +// unlike `docker image ls`, which matches all tags of a bare repository +// name. Match any tag under the repository instead. +func nameFilterFor(parsedReference *referenceutil.ImageReference) []string { + if parsedReference.ExplicitTag != "" || parsedReference.Digest != "" { + return []string{fmt.Sprintf("name==%s", parsedReference)} + } + return []string{fmt.Sprintf("name~=^%s:", regexp.QuoteMeta(parsedReference.Name()))} +} + func imagesAction(cmd *cobra.Command, args []string) error { options, err := listOptions(cmd, args) if err != nil { diff --git a/cmd/nerdctl/image/image_list_test.go b/cmd/nerdctl/image/image_list_test.go index c204fb95852..38c1e486c54 100644 --- a/cmd/nerdctl/image/image_list_test.go +++ b/cmd/nerdctl/image/image_list_test.go @@ -38,6 +38,54 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) +// TestNameFilterFor is a regression test for +// https://github.com/containerd/nerdctl/issues/5113: `nerdctl image ls +// myapp`, where myapp is a bare repository name, returned nothing unless +// myapp had a `:latest` tag, because referenceutil.Parse normalizes a bare +// repository name to an implicit ":latest" tag and the resulting exact-match +// filter therefore only ever matched that one tag. +func TestNameFilterFor(t *testing.T) { + testCases := []struct { + name string + arg string + expected []string + }{ + { + name: "bare repository name matches any tag", + arg: "myapp", + expected: []string{`name~=^docker\.io/library/myapp:`}, + }, + { + name: "explicit tag matches exactly", + arg: "myapp:v1", + expected: []string{"name==docker.io/library/myapp:v1"}, + }, + { + name: "explicit latest tag matches exactly", + arg: "myapp:latest", + expected: []string{"name==docker.io/library/myapp:latest"}, + }, + { + name: "digest matches exactly", + arg: "myapp@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + expected: []string{"name==docker.io/library/myapp@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + }, + { + name: "bare repository name with domain and path is escaped for the regex", + arg: "registry.example.com/foo/my.app", + expected: []string{`name~=^registry\.example\.com/foo/my\.app:`}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsedReference, err := referenceutil.Parse(tc.arg) + assert.NilError(t, err) + assert.DeepEqual(t, tc.expected, nameFilterFor(parsedReference)) + }) + } +} + func TestImages(t *testing.T) { nerdtest.Setup()