Skip to content
Open
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
98 changes: 92 additions & 6 deletions cmd/grype/cli/commands/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ $ grype completion fish > ~/.config/fish/completions/grype.fish
}
}

// targetSchemePrefixes is the set of scheme prefixes documented in the root command's long help text.
// Each entry includes the trailing ":" so that completion produces a value the user can keep typing
// after without having to add the separator themselves.
var targetSchemePrefixes = []string{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR. I think we will need to experiment with it to see if it "feels good" for real world use, but I would note that we have a --from flag which is also intended to replace the scheme. I'm not sure if autocompleting the scheme is ideal. We would also want to make sure the behavior is the same with --from specified.

"docker:",
"podman:",
"docker-archive:",
"oci-archive:",
"oci-dir:",
"singularity:",
"registry:",
"dir:",
"file:",
"sbom:",
"purl:",
"cpes:",
}

// imageSchemePrefixes is the subset of scheme prefixes for which we can usefully enumerate local
// Docker daemon images (the docker SDK speaks to both Docker and Podman daemons via DOCKER_HOST).
var imageSchemePrefixes = []string{
"docker:",
"podman:",
}

func listLocalDockerImages(prefix string) ([]string, error) {
var repoTags = make([]string, 0)
ctx := context.Background()
Expand Down Expand Up @@ -95,14 +120,75 @@ func listLocalDockerImages(prefix string) ([]string, error) {
return repoTags, nil
}

// schemePrefixCompletions returns the subset of known scheme prefixes that begin with toComplete.
// When the user has typed nothing yet, all known prefixes are returned.
func schemePrefixCompletions(toComplete string) []string {
matches := make([]string, 0, len(targetSchemePrefixes))
for _, p := range targetSchemePrefixes {
if strings.HasPrefix(p, toComplete) {
matches = append(matches, p)
}
}
return matches
}

// hasImageScheme reports whether toComplete starts with a scheme that we can enumerate via the
// local container daemon.
func hasImageScheme(toComplete string) (string, bool) {
for _, p := range imageSchemePrefixes {
if strings.HasPrefix(toComplete, p) {
return p, true
}
}
return "", false
}

// hasAnyTargetScheme reports whether toComplete starts with any known scheme prefix.
func hasAnyTargetScheme(toComplete string) bool {
for _, p := range targetSchemePrefixes {
if strings.HasPrefix(toComplete, p) {
return true
}
}
return false
}

func dockerImageValidArgsFunction(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided.
// When the docker daemon is unavailable (or has no images), fall through to the shell's default behavior so
// that subcommand completions and filename completion still work — returning ShellCompDirectiveError here
// causes shells (notably zsh) to discard all completions, including the auto-generated subcommand list.
dockerImageRepoTags, err := listLocalDockerImages(toComplete)
if err != nil || len(dockerImageRepoTags) == 0 {
// The scan target argument can be an image name (the historical default), or any of the documented scheme
// prefixes (registry:, dir:, file:, oci-archive:, ...). We suggest scheme prefixes when the partial input
// either matches a prefix or is empty, and we enumerate local container images when the input is plain or
// uses a daemon-backed scheme. For file- and dir-based schemes we fall through to the shell's default
// completion so it can expand paths after the colon.
if scheme, ok := hasImageScheme(toComplete); ok {
// strip the scheme to query the daemon, then re-attach the scheme to each suggestion
stripped := strings.TrimPrefix(toComplete, scheme)
tags, err := listLocalDockerImages(stripped)
if err != nil || len(tags) == 0 {
return nil, cobra.ShellCompDirectiveDefault
}
out := make([]string, 0, len(tags))
for _, t := range tags {
out = append(out, scheme+t)
}
return out, cobra.ShellCompDirectiveDefault
}

if hasAnyTargetScheme(toComplete) {
// a non-image scheme is in play (sbom:, dir:, file:, ...); let the shell complete the path
return nil, cobra.ShellCompDirectiveDefault
}

// no scheme typed yet: offer scheme prefixes plus the historical docker-image suggestions. We use
// ShellCompDirectiveNoSpace so that "dir:" can be followed by a path without the shell jumping to
// a new token, and ShellCompDirectiveDefault so the shell still offers filename completion when
// the user ignores our suggestions.
completions := schemePrefixCompletions(toComplete)
if tags, err := listLocalDockerImages(toComplete); err == nil {
completions = append(completions, tags...)
}
if len(completions) == 0 {
return nil, cobra.ShellCompDirectiveDefault
}
return dockerImageRepoTags, cobra.ShellCompDirectiveDefault
return completions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveDefault
}
158 changes: 158 additions & 0 deletions cmd/grype/cli/commands/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package commands

import (
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSchemePrefixCompletions(t *testing.T) {
tests := []struct {
name string
toComplete string
want []string
}{
{
name: "empty input returns all documented scheme prefixes",
toComplete: "",
want: targetSchemePrefixes,
},
{
name: "single letter narrows to matching prefixes",
toComplete: "o",
want: []string{"oci-archive:", "oci-dir:"},
},
{
name: "partial scheme returns just the matches",
toComplete: "oci-",
want: []string{"oci-archive:", "oci-dir:"},
},
{
name: "exact partial returns the single match",
toComplete: "sbom",
want: []string{"sbom:"},
},
{
name: "unrelated input returns no scheme matches",
toComplete: "xyz",
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := schemePrefixCompletions(tt.toComplete)
assert.Equal(t, tt.want, got)
})
}
}

func TestTargetSchemePrefixesCoverage(t *testing.T) {
// every scheme documented in the root command long-help text should be in the completion list,
// otherwise users typing one of those prefixes won't see it offered
expected := []string{
"docker:",
"podman:",
"docker-archive:",
"oci-archive:",
"oci-dir:",
"singularity:",
"registry:",
"dir:",
"file:",
"sbom:",
"purl:",
"cpes:",
}
for _, p := range expected {
assert.Containsf(t, targetSchemePrefixes, p, "scheme %q should be offered as a completion", p)
}
for _, p := range targetSchemePrefixes {
assert.Truef(t, strings.HasSuffix(p, ":"), "scheme prefix %q must end in ':' so users can keep typing", p)
}
}

func TestHasImageScheme(t *testing.T) {
tests := []struct {
input string
wantScheme string
wantOK bool
}{
{"docker:alpine", "docker:", true},
{"docker:", "docker:", true},
{"podman:fedora:39", "podman:", true},
{"registry:gcr.io/foo", "", false},
{"dir:/tmp", "", false},
{"alpine:3.18", "", false},
{"", "", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
scheme, ok := hasImageScheme(tt.input)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.wantScheme, scheme)
})
}
}

func TestHasAnyTargetScheme(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"docker:alpine", true},
{"dir:/tmp/proj", true},
{"sbom:./sbom.json", true},
{"registry:gcr.io/foo", true},
{"oci-archive:/tmp/img.tar", true},
{"alpine:3.18", false},
{"./local/path", false},
{"", false},
{"unknown-scheme:foo", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.want, hasAnyTargetScheme(tt.input))
})
}
}

func TestDockerImageValidArgsFunction_OffersSchemesOnEmptyInput(t *testing.T) {
// when the user has typed nothing, the function should return every documented scheme prefix.
// We don't care whether a docker daemon is reachable in CI; we only assert the scheme prefixes
// are present in the suggestions. The directive should request NoSpace so that "dir:" can be
// followed by a path without the shell adding a separator.
cmd := &cobra.Command{Use: "grype"}
got, directive := dockerImageValidArgsFunction(cmd, nil, "")
for _, p := range targetSchemePrefixes {
assert.Containsf(t, got, p, "expected scheme prefix %q in completions", p)
}
assert.NotZero(t, directive&cobra.ShellCompDirectiveNoSpace, "expected NoSpace directive bit so user can keep typing after the colon")
}

func TestDockerImageValidArgsFunction_FileSchemeFallsThroughToShell(t *testing.T) {
// when a non-image scheme is in play, defer to the shell for path completion. We should return
// no suggestions and the Default directive so the shell offers filename completion.
cmd := &cobra.Command{Use: "grype"}
got, directive := dockerImageValidArgsFunction(cmd, nil, "dir:/tmp/")
assert.Empty(t, got)
assert.Equal(t, cobra.ShellCompDirectiveDefault, directive)
}

func TestDockerImageValidArgsFunction_PartialSchemeNarrows(t *testing.T) {
// when the user has typed a partial scheme that doesn't fully match any prefix, only matching
// prefixes should be offered.
cmd := &cobra.Command{Use: "grype"}
got, _ := dockerImageValidArgsFunction(cmd, nil, "oci-")
require.NotEmpty(t, got)
assert.Contains(t, got, "oci-archive:")
assert.Contains(t, got, "oci-dir:")
for _, c := range got {
// since we matched on "oci-", non-matching schemes should not be returned (docker-image
// fallback is also limited by the same prefix on the daemon side, so we don't expect
// arbitrary images either)
assert.Truef(t, strings.HasPrefix(c, "oci-"), "completion %q should start with the partial 'oci-' the user typed", c)
}
}
Loading