Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion cmd/nerdctl/image/image_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package image

import (
"fmt"
"regexp"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions cmd/nerdctl/image/image_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading