Repository: regclient/regclient
Pull request #361 (2023-02-15 01:23:08)

Adding regctl artifact tree
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

This adds a `regctl artifact tree` command to visualize multiple levels of artifacts.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```
$ regctl artifact tree ghcr.io/regclient/regctl:edge
Ref: ghcr.io/regclient/regctl:edge
Digest: sha256:87f3d11f446a99648d958e031deec4989dcb1c5f199c4c72e7b3ead8910ea89f
Children:
  - sha256:6be79745a8eec215eb1f9a4ab86b5b1f3fb80659f443f0b85821037b9477b616 [linux/386]
    Referrers:
      - sha256:81e9a3c4c15a667d58c3a534bb9602726c45f86620f6bb04d2425c7e3712ca90: application/vnd.oci.image.config.v1+json
      - sha256:fbf23884449599649006ed8782edbb2fcda80b8182a015d11fac55acdf904990: application/vnd.cyclonedx+json
      - sha256:1cc3b61e166ed96d12c7fc3dd83c55e3928a9bd8050a7ad808586851086f2f38: application/spdx+json
  - sha256:e51e7e4e051d06d21f3de3947b4d3db4f741b4b18a47d37bd6e6290945894d5c [linux/amd64]
    Referrers:
      - sha256:44312a0c394753d69300fbe2334b872282d84e79bb4cfc08fe7bad6b7fd3ccd9: application/vnd.oci.image.config.v1+json
      - sha256:ed07c2297288b60032563c3137be962e9acb202719c054da2dd626bd8cca2e03: application/vnd.cyclonedx+json
      - sha256:b5f4948ac9824fafd801ca5d631a67cad3e289a6a07d14806b2e7b04dd63a041: application/spdx+json
  - sha256:7f1c3eab9366425a0b7d24b11345396fb92c35b66ca63556155bf2c0ef816fbd [linux/arm/v6]
    Referrers:
      - sha256:79542743c8d6a4aa07c0b96e7d01392cc46d44d0788b2452dae1fd5669dde6a7: application/vnd.oci.image.config.v1+json
      - sha256:6643ed609edff4ceaa603e1fc379a834e8dbdfc7181dd2037bf8615506ea2c88: application/vnd.cyclonedx+json
      - sha256:f12264213b0b96ae6dde30a4ea84047426081d5f19fcdad240ab8cf8b8778fb8: application/spdx+json
  - sha256:ffd75805c2b931b1e351f3bc4e0a16c9dc8410af91c77333d7009071613a7bf9 [linux/arm/v7]
    Referrers:
      - sha256:c533cd96b68a68675dae75adc8d445ac52ee56a63b4370406e4d3a73a4dca7b8: application/vnd.oci.image.config.v1+json
      - sha256:5b3abc6839ebe0c57d44c7f6eb69f8c1c65b414235e7351a2f351cd7c16e24f3: application/vnd.cyclonedx+json
      - sha256:b07ccffac4cca269860b47075174eb1d79f9e15de51b461ebc66993dfc7e915d: application/spdx+json
  - sha256:dc32bd1dded11e9136a38e91dd2fe7ab5206ff0e55c989694ccf7e5e941a8b80 [linux/arm64]
    Referrers:
      - sha256:796b0799c47e152082842e93d33a4fc0303723c4387c344b67e0fcc48f0668bb: application/vnd.oci.image.config.v1+json
      - sha256:4cb1b4e1a083253beb7cccd045b58d752c17668ac944f3f82e6e662e095448e1: application/vnd.cyclonedx+json
      - sha256:7f1f33fb070cbc2f623329abec0910fcbb14e80b4bae1327d912560dc864d8b4: application/spdx+json
  - sha256:b2e5009c9c05fec86f73ae692b453e84efd3c81ae14bcfc216b990363807617a [linux/ppc64le]
    Referrers:
      - sha256:4b388873b6d7f06352e0db2ffba78ced19047b8ec3c74411c08b1c26295125da: application/vnd.oci.image.config.v1+json
      - sha256:d651bbaced44d3c05ee5e700c517f41d9337e0a49fce489e8ab52edf196a3f55: application/vnd.cyclonedx+json
      - sha256:44111ad98c4e78701f6659cef473c828077549216b5d9f2073ba6cdb25cd96dd: application/spdx+json
  - sha256:979cec0932de7c93fa2368ba4c3fa5b9316f5266962b94caed94898b430cf59f [linux/s390x]
    Referrers:
      - sha256:26cea34040712b78dfb5182601307db211887df848248b2fdefcb5330faf8dd2: application/vnd.oci.image.config.v1+json
      - sha256:151dc37613d8a22e6b607de6c584d4b9369df77f5d2a80608c29416d38fb6247: application/vnd.cyclonedx+json
      - sha256:638233dc2337a9a21f227c0229a32d5e309c272cf3d5483352f06295e76e00ef: application/spdx+json
```

<!-- Include steps that can be taken to verify the change -->

### Changelog text

Add `regctl artifact tree` command
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed



Patch:
diff --git a/cmd/regctl/artifact.go b/cmd/regctl/artifact.go
index b3d494e..41ab730 100644
--- a/cmd/regctl/artifact.go
+++ b/cmd/regctl/artifact.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"bytes"
+	"context"
 	"fmt"
 	"io"
 	"os"
@@ -14,6 +15,7 @@ import (
 	_ "crypto/sha512"
 
 	"github.com/opencontainers/go-digest"
+	"github.com/regclient/regclient"
 	"github.com/regclient/regclient/pkg/archive"
 	"github.com/regclient/regclient/pkg/template"
 	"github.com/regclient/regclient/scheme"
@@ -79,6 +81,15 @@ var artifactPutCmd = &cobra.Command{
 	ValidArgs: []string{}, // do not auto complete repository/tag
 	RunE:      runArtifactPut,
 }
+var artifactTreeCmd = &cobra.Command{
+	Use:       "tree <reference>",
+	Aliases:   []string{},
+	Short:     "tree listing of artifacts",
+	Long:      `Return a graph of manifests and referrers to those manifests.`,
+	Args:      cobra.ExactArgs(1),
+	ValidArgs: []string{}, // do not auto complete repository/tag
+	RunE:      runArtifactTree,
+}
 
 var artifactOpts struct {
 	annotations    []string
@@ -92,6 +103,7 @@ var artifactOpts struct {
 	filterAnnot    []string
 	formatList     string
 	formatPut      string
+	formatTree     string
 	outputDir      string
 	platform       string
 	refers         string
@@ -143,9 +155,14 @@ func init() {
 	artifactPutCmd.Flags().StringVarP(&artifactOpts.refers, "refers", "", "", "EXPERIMENTAL: Set a referrer to the reference")
 	artifactPutCmd.Flags().MarkHidden("refers")
 
+	artifactTreeCmd.Flags().StringVarP(&artifactOpts.filterAT, "filter-artifact-type", "", "", "Filter descriptors by artifactType")
+	artifactTreeCmd.Flags().StringArrayVarP(&artifactOpts.filterAnnot, "filter-annotation", "", []string{}, "Filter descriptors by annotation (key=value)")
+	artifactTreeCmd.Flags().StringVarP(&artifactOpts.formatTree, "format", "", "{{printPretty .}}", "Format output with go template syntax")
+
 	artifactCmd.AddCommand(artifactGetCmd)
 	artifactCmd.AddCommand(artifactListCmd)
 	artifactCmd.AddCommand(artifactPutCmd)
+	artifactCmd.AddCommand(artifactTreeCmd)
 	rootCmd.AddCommand(artifactCmd)
 }
 
@@ -696,3 +713,207 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 	}
 	return template.Writer(os.Stdout, artifactOpts.formatPut, result)
 }
+
+func runArtifactTree(cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+
+	// validate inputs
+	r, err := ref.New(args[0])
+	if err != nil {
+		return err
+	}
+
+	rc := newRegClient()
+	defer rc.Close(ctx, r)
+
+	referrerOpts := []scheme.ReferrerOpts{}
+	if artifactOpts.filterAT != "" {
+		referrerOpts = append(referrerOpts, scheme.WithReferrerAT(artifactOpts.filterAT))
+	}
+	if artifactOpts.filterAnnot != nil {
+		af := map[string]string{}
+		for _, kv := range artifactOpts.filterAnnot {
+			kvSplit := strings.SplitN(kv, "=", 2)
+			if len(kvSplit) == 2 {
+				af[kvSplit[0]] = kvSplit[1]
+			} else {
+				af[kv] = ""
+			}
+		}
+		referrerOpts = append(referrerOpts, scheme.WithReferrerAnnotations(af))
+	}
+
+	seen := []string{}
+	tr, err := treeAddResult(ctx, rc, r, seen, referrerOpts)
+	if err != nil {
+		return err
+	}
+
+	return template.Writer(cmd.OutOrStdout(), artifactOpts.formatTree, tr)
+}
+
+func treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen []string, rOpts []scheme.ReferrerOpts) (*treeResult, error) {
+	tr := treeResult{
+		Ref: r,
+	}
+
+	// get manifest
+	m, err := rc.ManifestGet(ctx, r)
+	if err != nil {
+		return nil, err
+	}
+	tr.Manifest = m
+
+	// track already seen manifests
+	dig := m.GetDescriptor().Digest.String()
+	if sliceHasStr(seen, dig) {
+		// loop detected, consider making this an error
+		return &tr, fmt.Errorf("loop detected")
+	}
+	seen = append(seen, dig)
+
+	// get child nodes
+	if m.IsList() {
+		tr.Child = []*treeResult{}
+		mi, ok := m.(manifest.Indexer)
+		if !ok {
+			return nil, fmt.Errorf("failed to convert a manifest list to indexer for %s", r.CommonName())
+		}
+		dl, err := mi.GetManifestList()
+		if err != nil {
+			return nil, fmt.Errorf("failed to get platforms for %s: %w", r.CommonName(), err)
+		}
+		for _, d := range dl {
+			rChild := r
+			rChild.Tag = ""
+			rChild.Digest = d.Digest.String()
+			tChild, err := treeAddResult(ctx, rc, rChild, seen, rOpts)
+			if err != nil {
+				return nil, err
+			}
+			tChild.ArtifactType = d.ArtifactType
+			if d.Platform != nil {
+				pCopy := *d.Platform
+				tChild.Platform = &pCopy
+			}
+			tr.Child = append(tr.Child, tChild)
+		}
+	}
+
+	// get referrers
+	rl, err := rc.ReferrerList(ctx, r, rOpts...)
+	if err != nil {
+		return nil, fmt.Errorf("failed to check referrers for %s: %w", r.CommonName(), err)
+	}
+	if len(rl.Descriptors) > 0 {
+		tr.Referrer = []*treeResult{}
+		for _, d := range rl.Descriptors {
+			rReferrer := r
+			rReferrer.Tag = ""
+			rReferrer.Digest = d.Digest.String()
+			tReferrer, err := treeAddResult(ctx, rc, rReferrer, seen, rOpts)
+			if err != nil {
+				return nil, err
+			}
+			tReferrer.ArtifactType = d.ArtifactType
+			if d.Platform != nil {
+				pCopy := *d.Platform
+				tReferrer.Platform = &pCopy
+			}
+			tr.Referrer = append(tr.Referrer, tReferrer)
+		}
+	}
+
+	return &tr, nil
+}
+
+func sliceHasStr(list []string, search string) bool {
+	for _, el := range list {
+		if el == search {
+			return true
+		}
+	}
+	return false
+}
+
+type treeResult struct {
+	Ref          ref.Ref            `json:"reference"`
+	Manifest     manifest.Manifest  `json:"manifest"`
+	Platform     *platform.Platform `json:"platform,omitempty"`
+	ArtifactType string             `json:"artifactType,omitempty"`
+	Child        []*treeResult      `json:"child,omitempty"`
+	Referrer     []*treeResult      `json:"referrer,omitempty"`
+}
+
+func (tr *treeResult) MarshalPretty() ([]byte, error) {
+	mp, err := tr.marshalPretty("")
+	if err != nil {
+		return nil, err
+	}
+	return []byte(fmt.Sprintf("Ref: %s\nDigest: %s", tr.Ref.CommonName(), mp)), nil
+}
+
+func (tr *treeResult) marshalPretty(indent string) ([]byte, error) {
+	result := bytes.NewBufferString("")
+	_, err := result.WriteString(tr.Manifest.GetDescriptor().Digest.String())
+	if err != nil {
+		return nil, err
+	}
+	if tr.Platform != nil {
+		_, err = result.WriteString(" [" + tr.Platform.String() + "]")
+		if err != nil {
+			return nil, err
+		}
+	}
+	if tr.ArtifactType != "" {
+		_, err = result.WriteString(": " + tr.ArtifactType)
+		if err != nil {
+			return nil, err
+		}
+	}
+	_, err = result.WriteString("\n")
+	if err != nil {
+		return nil, err
+	}
+	if len(tr.Child) > 0 {
+		_, err = result.WriteString(indent + "Children:\n")
+		if err != nil {
+			return nil, err
+		}
+		for _, trChild := range tr.Child {
+			_, err = result.WriteString(indent + "  - ")
+			if err != nil {
+				return nil, err
+			}
+			childBytes, err := trChild.marshalPretty(indent + "    ")
+			if err != nil {
+				return nil, err
+			}
+			_, err = result.Write(childBytes)
+			if err != nil {
+				return nil, err
+			}
+		}
+	}
+	if len(tr.Referrer) > 0 {
+		_, err = result.WriteString(indent + "Referrers:\n")
+		if err != nil {
+			return nil, err
+		}
+		for _, trReferrer := range tr.Referrer {
+			_, err = result.WriteString(indent + "  - ")
+			if err != nil {
+				return nil, err
+			}
+			referrerBytes, err := trReferrer.marshalPretty(indent + "    ")
+			if err != nil {
+				return nil, err
+			}
+			_, err = result.Write(referrerBytes)
+			if err != nil {
+				return nil, err
+			}
+		}
+	}
+	return result.Bytes(), nil
+}
diff --git a/docs/regctl.md b/docs/regctl.md
index c9c3f7a..21ff852 100644
--- a/docs/regctl.md
+++ b/docs/regctl.md
@@ -332,6 +332,7 @@ Available Commands:
   get         download artifacts
   list        list artifacts that have a subject to the given reference
   put         upload artifacts
+  tree        tree listing of artifacts
 ```
 
 The `get` command retrieves an artifact from the registry.
@@ -355,6 +356,9 @@ A single file may be pushed using stdin.
 To set annotations on the manifest, use `--annotation name=value`, and repeat the flag for additional annotations.
 The format option includes `.Manifest` which supports methods from [manifest.Manifest](https://pkg.go.dev/github.com/regclient/regclient/types/manifest#Manifest).
 
+The `tree` command is useful for visualizing a multi-level structure of manifests and artifacts referring to the manifests.
+Referrers may be filtered by artifact type and annotation with `--filter-artifact-type` and `--filter-annotation`.
+
 The following demonstrates uploading a simple artifact from stdin/stdout:
 
 ```shell
@@ -365,24 +369,24 @@ $ regctl artifact put \
 Test artifact from regctl.
 This follows the OCI artifact format
 EOF
-sha256:36484d44383fc9ffd34be11da4a617a96cb06b912c98114bfdb6ad2dddd443e2
+sha256:5ff19e3084ae3e7b2317904db06012a71323fcae0a29e31373031ae1446384f6
 
 $ regctl manifest get localhost:5000/artifact:demo
 Name:        localhost:5000/artifact:demo
 MediaType:   application/vnd.oci.image.manifest.v1+json
-Digest:      sha256:36484d44383fc9ffd34be11da4a617a96cb06b912c98114bfdb6ad2dddd443e2
-Annotations: 
+Digest:      sha256:5ff19e3084ae3e7b2317904db06012a71323fcae0a29e31373031ae1446384f6
+Annotations:
   demo:      true
   format:    oci
 Total Size:  64B
-             
-Config:      
+
+Config:
   Digest:    sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
-  MediaType: application/vnd.unknown.config.v1+json
+  MediaType: application/vnd.unknown.config+json
   Size:      2B
-             
-Layers:      
-             
+
+Layers:
+
   Digest:    sha256:7f8028bb058b780630dcd31cde93cb3efe96d60108ffbfe2727e4e76fdf4c9dc
   MediaType: application/octet-stream
   Size:      64B
@@ -448,6 +452,25 @@ Type: OCI
 Contains: Image
 ```
 
+The `tree` command shows the multi-platform image and the referrers on the root of the tree.
+
+```shell
+$ regctl artifact tree localhost:5000/artifacts:v1
+Ref: localhost:5000/artifacts:v1
+Digest: sha256:bc41182d7ef5ffc53a40b044e725193bc10142a1243f395ee852a8d9730fc2ad
+Children:
+  - sha256:1304f174557314a7ed9eddb4eab12fed12cb0cd9809e4c28f29af86979a3c870 [linux/amd64]
+  - sha256:5da989a9a3a08357bc7c00bd46c3ed782e1aeefc833e0049e6834ec1dcad8a42 [linux/arm/v6]
+  - sha256:0c673ee68853a04014c0c623ba5ee21ee700e1d71f7ac1160ddb2e31c6fdbb18 [linux/arm/v7]
+  - sha256:ed73e2bee79b3428995b16fce4221fc715a849152f364929cdccdc83db5f3d5c [linux/arm64]
+  - sha256:1d96e60e5270815238e999aed0ae61d22ac6f5e5f976054b24796d0e0158b39c [linux/386]
+  - sha256:fa30af02cc8c339dd7ffecb0703cd4a3db175e56875c413464c5ba46821253a8 [linux/ppc64le]
+  - sha256:c2046a6c3d2db4f75bfb8062607cc8ff47896f2d683b7f18fe6b6cf368af3c60 [linux/s390x]
+Referrers:
+  - sha256:80024f564d15a8e3593aac53d2ebaf62cad3db0b873ab66946b016cd65cc5728: application/vnd.example.sbom
+  - sha256:70440b27e1ebccf4627b10100421db022202a06a43d218ebadfdfd64c92f4c94: application/vnd.example.sbom
+```
+
 ## Format Flag
 
 The `--format` flag allows you to apply a Go template to the output of some commands.
diff --git a/scheme/ocidir/referrer.go b/scheme/ocidir/referrer.go
index abbfe5f..e397d96 100644
--- a/scheme/ocidir/referrer.go
+++ b/scheme/ocidir/referrer.go
@@ -94,7 +94,9 @@ func (o *OCIDir) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme.Ref
 	for k, v := range config.FilterAnnotation {
 		if len(rl.Descriptors) > 0 {
 			for i := len(rl.Descriptors) - 1; i >= 0; i-- {
-				if rl.Descriptors[i].Annotations == nil || rl.Descriptors[i].Annotations[k] != v {
+				if rl.Descriptors[i].Annotations == nil {
+					rl.Descriptors = append(rl.Descriptors[:i], rl.Descriptors[i+1:]...)
+				} else if rlVal, ok := rl.Descriptors[i].Annotations[k]; !ok || v != "" && rlVal != v {
 					rl.Descriptors = append(rl.Descriptors[:i], rl.Descriptors[i+1:]...)
 				}
 			}
diff --git a/scheme/reg/referrer.go b/scheme/reg/referrer.go
index 8d70f8d..a348117 100644
--- a/scheme/reg/referrer.go
+++ b/scheme/reg/referrer.go
@@ -85,7 +85,9 @@ func (reg *Reg) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme.Refe
 	for k, v := range config.FilterAnnotation {
 		if len(rl.Descriptors) > 0 {
 			for i := len(rl.Descriptors) - 1; i >= 0; i-- {
-				if rl.Descriptors[i].Annotations == nil || rl.Descriptors[i].Annotations[k] != v {
+				if rl.Descriptors[i].Annotations == nil {
+					rl.Descriptors = append(rl.Descriptors[:i], rl.Descriptors[i+1:]...)
+				} else if rlVal, ok := rl.Descriptors[i].Annotations[k]; !ok || v != "" && rlVal != v {
 					rl.Descriptors = append(rl.Descriptors[:i], rl.Descriptors[i+1:]...)
 				}
 			}


Test patch:
diff --git a/cmd/regctl/artifact_test.go b/cmd/regctl/artifact_test.go
new file mode 100644
index 0000000..670a6f2
--- /dev/null
+++ b/cmd/regctl/artifact_test.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/regclient/regclient/types"
+)
+
+func TestArtifactTree(t *testing.T) {
+	tt := []struct {
+		name        string
+		args        []string
+		expectErr   error
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "Missing arg",
+			args:      []string{"artifact", "tree"},
+			expectErr: fmt.Errorf("accepts 1 arg(s), received 0"),
+		},
+		{
+			name:      "Invalid ref",
+			args:      []string{"artifact", "tree", "invalid*ref"},
+			expectErr: fmt.Errorf("invalid reference \"%s\"", "invalid*ref"),
+		},
+		{
+			name:      "Missing manifest",
+			args:      []string{"artifact", "tree", "ocidir://../../testdata/testrepo:missing"},
+			expectErr: types.ErrNotFound,
+		},
+		{
+			name:        "No referrers",
+			args:        []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v1"},
+			expectOut:   "Children",
+			outContains: true,
+		},
+		{
+			name:        "Referrers",
+			args:        []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2"},
+			expectOut:   "Referrers",
+			outContains: true,
+		},
+		{
+			name:        "Filter",
+			args:        []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom"},
+			expectOut:   "application/example.sbom",
+			outContains: true,
+		},
+		{
+			name:      "Filter and Format",
+			args:      []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom", "--format", "{{ ( index .Referrer 0 ).ArtifactType }}"},
+			expectOut: "application/example.sbom",
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			out, err := cobraTest(t, tc.args...)
+			if tc.expectErr != nil {
+				if err == nil {
+					t.Errorf("did not receive expected error: %v", tc.expectErr)
+				} else if !errors.Is(err, tc.expectErr) && err.Error() != tc.expectErr.Error() {
+					t.Errorf("unexpected error, received %v, expected %v", err, tc.expectErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Errorf("returned unexpected error: %v", err)
+				return
+			}
+			if (!tc.outContains && out != tc.expectOut) || (tc.outContains && !strings.Contains(out, tc.expectOut)) {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+
+}
diff --git a/cmd/regctl/main_test.go b/cmd/regctl/main_test.go
new file mode 100644
index 0000000..52f5edf
--- /dev/null
+++ b/cmd/regctl/main_test.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+)
+
+func cobraTest(t *testing.T, args ...string) (string, error) {
+	t.Helper()
+
+	buf := new(bytes.Buffer)
+	rootCmd.SetOut(buf)
+	rootCmd.SetErr(buf)
+	rootCmd.SetArgs(args)
+
+	err := rootCmd.Execute()
+	return strings.TrimSpace(buf.String()), err
+}
diff --git a/scheme/ocidir/referrer_test.go b/scheme/ocidir/referrer_test.go
index 05472b6..03bcedf 100644
--- a/scheme/ocidir/referrer_test.go
+++ b/scheme/ocidir/referrer_test.go
@@ -271,6 +271,15 @@ func TestReferrer(t *testing.T) {
 			t.Errorf("unexpected descriptors")
 			return
 		}
+		rl, err = o.ReferrerList(ctx, r, scheme.WithReferrerAnnotations(map[string]string{extraAnnot: ""}))
+		if err != nil {
+			t.Errorf("Failed running ReferrerList: %v", err)
+			return
+		}
+		if len(rl.Descriptors) != 2 {
+			t.Errorf("descriptor list length, expected 2, received %d", len(rl.Descriptors))
+			return
+		}
 	})
 	// list platform=linux/amd64
 	t.Run("List Annotation for Platform", func(t *testing.T) {
diff --git a/scheme/reg/referrer_test.go b/scheme/reg/referrer_test.go
index f6b2833..0f7ccb4 100644
--- a/scheme/reg/referrer_test.go
+++ b/scheme/reg/referrer_test.go
@@ -1055,6 +1055,15 @@ func TestReferrer(t *testing.T) {
 			t.Errorf("unexpected descriptors: %v", rl.Descriptors)
 			return
 		}
+		rl, err = reg.ReferrerList(ctx, r, scheme.WithReferrerAnnotations(map[string]string{extraAnnot: ""}))
+		if err != nil {
+			t.Errorf("Failed running ReferrerList: %v", err)
+			return
+		}
+		if len(rl.Descriptors) != 2 {
+			t.Errorf("descriptor list mismatch: %v", rl.Descriptors)
+			return
+		}
 	})
 
 	t.Run("List with artifact filter Ext API", func(t *testing.T) {