Repository: regclient/regclient (go)

Issue:
[Feature] artifacts referring to subjects in different repositories
### Current Behavior

`regctl artifact put --subject <subject-ref> <artifact-ref>` fails with `reference and subject must be in the same repository` if `artifact-ref` and `subject-ref` are on different repositories, but in the same registry

### Expected Behavior

`artifact-ref` and `subject-ref` on the same registry are accepted, no matter if they are on different repositories or not.

### Steps To Reproduce

Following script:
```
#!/usr/bin/env bash

set -eu

cleanup() {
    docker rm -f test-subject
}

trap cleanup EXIT

docker run -d -p 5123:5000 --name test-subject ghcr.io/project-zot/zot-linux-arm64:latest
sleep 3

regctl registry set --tls disabled

dd if=/dev/urandom of=./artifact.file bs=1M count=10

skopeo copy --override-os linux --dest-tls-verify=false docker://docker.io/library/alpine:3.18.4 docker://localhost:5123/alpine:3.18.4

echo
echo "🤨 does putting an artifact work?"
regctl artifact put --file ./artifact.file localhost:5123/alpine:v1
echo "✅ Putting an artifact works"

echo
echo "🤨 does putting an artifact with a subject in the same repository work?"
regctl artifact put --subject localhost:5123/alpine:3.18.4 --file ./artifact.file localhost:5123/alpine:v2
echo "✅ Putting an artifact with a subject in the same repository works"

echo
echo "🤨 does putting an artifact with a subject in a different repository work?"
regctl artifact put --subject localhost:5123/alpine:3.18.4 --file ./artifact.file localhost:5123/the-subject-repo:v1
echo "✅ Putting an artifact with a subject in a different repository works"
```

### Version

```text
VCSTag:     v0.5.3
VCSRef:     63bcd1a551acbb357d6a6aea0c50fb193aa77da9
VCSCommit:  63bcd1a551acbb357d6a6aea0c50fb193aa77da9
VCSState:   clean
VCSDate:    2023-10-06T20:40:08Z
Platform:   darwin/arm64
GoVer:      go1.21.1
GoCompiler: gc
```

### Environment

- Running as binary or container: as binary
- Host platform: MacOS
- Registry description: Zot

### Anything else

<!--
Links? References? Anything that will give us more context about the issue that you are encountering!
-->


Pull request description:
Feat: Support referrers in an external repository
<!--

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

Fixes #586.
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

Support referrers in an external repository. This allows organizations to maintain a repository of referrers separate from the upstream images. That can be useful for SBOM, signing, and vulnerability scans that are under separate security zones.

<!-- 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

```shell
$ echo "Alpine Linux" | regctl artifact put --subject alpine ocidir://test/external:alpine-info --artifact-type application/example.contents

$ regctl artifact list alpine --external ocidir://test/external
Subject:        ocidir://test/external@sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a
                
Referrers:      
                
  Name:         ocidir://test/external@sha256:5f685e999e50aecd719512de9443c9bac27f60056580f6c3bdaf18d75c1c9ab3
  Digest:       sha256:5f685e999e50aecd719512de9443c9bac27f60056580f6c3bdaf18d75c1c9ab3
  MediaType:    application/vnd.oci.image.manifest.v1+json
  ArtifactType: application/example.contents

$ regctl artifact tree alpine --external ocidir://test/external
Ref: docker.io/library/alpine:latest
Digest: sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a
Children:
  - sha256:029a752048e32e843bd6defe3841186fb8d19a28dae8ec287f433bb9d6d1ad85 [linux/amd64]
  - sha256:5fea95373b9ec85974843f31446fa6a9df4492dddae4e1cb056193c34a20a5be [unknown/unknown]
  - sha256:b4aef1a899e0271f06d948c9a8fa626ecdb2202d3a178bc14775dd559e23df8e [linux/arm/v6]
  - sha256:a4d1e27e63a9d6353046eb25a2f0ec02945012b217f4364cd83a73fe6dfb0b15 [unknown/unknown]
  - sha256:4fdafe217d0922f3c3e2b4f64cf043f8403a4636685cd9c51fea2cbd1f419740 [linux/arm/v7]
  - sha256:7f21ac2018d95b2c51a5779c1d5ca6c327504adc3b0fdc747a6725d30b3f13c2 [unknown/unknown]
  - sha256:ea3c5a9671f7b3f7eb47eab06f73bc6591df978b0d5955689a9e6f943aa368c0 [linux/arm64]
  - sha256:a8ba68c1a9e6eea8041b4b8f996c235163440808b9654a865976fdcbede0f433 [unknown/unknown]
  - sha256:dea9f02e103e837849f984d5679305c758aba7fea1b95b7766218597f61a05ab [linux/386]
  - sha256:3c6629bec05c8273a927d46b77428bf4a378dad911a0ae284887becdc149b734 [unknown/unknown]
  - sha256:0880443bffa028dfbbc4094a32dd6b7ac25684e4c0a3d50da9e0acae355c5eaf [linux/ppc64le]
  - sha256:bb48308f976b266e3ab39bbf9af84521959bd9c295d3c763690cf41f8df2a626 [unknown/unknown]
  - sha256:d76e6fbe348ff20c2931bb7f101e49379648e026de95dd37f96e00ce1909dcf7 [linux/riscv64]
  - sha256:dd807544365f6dc187cbe6de0806adce2ea9de3e7124717d1d8e8b7a18b77b64 [unknown/unknown]
  - sha256:b815fadf80495594eb6296a6af0bc647ae5f193e0044e07acec7e5b378c9ce2d [linux/s390x]
  - sha256:74681be74a280a88abb53ff1e048eb1fb624b30d0066730df6d8afd02ba82e01 [unknown/unknown]
Referrers:
  - sha256:5f685e999e50aecd719512de9443c9bac27f60056580f6c3bdaf18d75c1c9ab3: application/example.contents

$ regctl artifact get --subject alpine --external ocidir://test/external --filter-artifact-type application/example.contents
Alpine Linux
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Feat: Support referrers in an external repository.
<!-- 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

<!-- markdownlint-disable-file MD041 -->


Patch:
diff --git a/cmd/regctl/artifact.go b/cmd/regctl/artifact.go
index bf60cdc..ea4b432 100644
--- a/cmd/regctl/artifact.go
+++ b/cmd/regctl/artifact.go
@@ -68,6 +68,7 @@ type artifactCmd struct {
 	artifactTitle    bool
 	byDigest         bool
 	digestTags       bool
+	externalRepo     string
 	filterAT         string
 	filterAnnot      []string
 	formatList       string
@@ -180,6 +181,7 @@ regctl artifact tree --digest-tags ghcr.io/regclient/regsync:latest`,
 	}
 
 	artifactGetCmd.Flags().StringVar(&artifactOpts.subject, "subject", "", "Get a referrer to the subject reference")
+	artifactGetCmd.Flags().StringVar(&artifactOpts.externalRepo, "external", "", "Query referrers from a separate source")
 	artifactGetCmd.Flags().StringVarP(&artifactOpts.platform, "platform", "p", "", "Specify platform of a subject (e.g. linux/amd64 or local)")
 	artifactGetCmd.Flags().StringVar(&artifactOpts.filterAT, "filter-artifact-type", "", "Filter referrers by artifactType")
 	artifactGetCmd.Flags().StringArrayVar(&artifactOpts.filterAnnot, "filter-annotation", []string{}, "Filter referrers by annotation (key=value)")
@@ -199,6 +201,7 @@ regctl artifact tree --digest-tags ghcr.io/regclient/regsync:latest`,
 	artifactGetCmd.Flags().BoolVar(&artifactOpts.sortDesc, "sort-desc", false, "Sort in descending order")
 
 	artifactListCmd.Flags().BoolVar(&artifactOpts.digestTags, "digest-tags", false, "Include digest tags")
+	artifactListCmd.Flags().StringVar(&artifactOpts.externalRepo, "external", "", "Query referrers from a separate source")
 	artifactListCmd.Flags().StringVar(&artifactOpts.filterAT, "filter-artifact-type", "", "Filter descriptors by artifactType")
 	artifactListCmd.Flags().StringArrayVar(&artifactOpts.filterAnnot, "filter-annotation", []string{}, "Filter descriptors by annotation (key=value)")
 	artifactListCmd.Flags().StringVar(&artifactOpts.formatList, "format", "{{printPretty .}}", "Format output with go template syntax")
@@ -236,6 +239,7 @@ regctl artifact tree --digest-tags ghcr.io/regclient/regsync:latest`,
 	_ = artifactPutCmd.Flags().MarkHidden("refers")
 
 	artifactTreeCmd.Flags().BoolVar(&artifactOpts.digestTags, "digest-tags", false, "Include digest tags")
+	artifactTreeCmd.Flags().StringVar(&artifactOpts.externalRepo, "external", "", "Query referrers from a separate source")
 	artifactTreeCmd.Flags().StringVar(&artifactOpts.filterAT, "filter-artifact-type", "", "Filter descriptors by artifactType")
 	artifactTreeCmd.Flags().StringArrayVar(&artifactOpts.filterAnnot, "filter-annotation", []string{}, "Filter descriptors by annotation (key=value)")
 	artifactTreeCmd.Flags().StringVar(&artifactOpts.formatTree, "format", "{{printPretty .}}", "Format output with go template syntax")
@@ -258,6 +262,9 @@ func (artifactOpts *artifactCmd) runArtifactGet(cmd *cobra.Command, args []strin
 			artifactOpts.subject = artifactOpts.refers
 		}
 	}
+	if artifactOpts.externalRepo != "" && artifactOpts.subject == "" {
+		artifactOpts.rootOpts.log.Warn("--external option depends on --subject")
+	}
 	if artifactOpts.latest && artifactOpts.sortAnnot != "" {
 		return fmt.Errorf("--latest cannot be used with --sort-annotation")
 	}
@@ -319,6 +326,16 @@ func (artifactOpts *artifactCmd) runArtifactGet(cmd *cobra.Command, args []strin
 		if artifactOpts.platform != "" {
 			referrerOpts = append(referrerOpts, scheme.WithReferrerPlatform(artifactOpts.platform))
 		}
+		if artifactOpts.externalRepo != "" {
+			rExt, err := ref.New(artifactOpts.externalRepo)
+			if err != nil {
+				return fmt.Errorf("failed to parse external ref: %w", err)
+			}
+			referrerOpts = append(referrerOpts, scheme.WithReferrerSource(rExt))
+			r = rExt
+		} else {
+			r = rSubject
+		}
 		rl, err := rc.ReferrerList(ctx, rSubject, referrerOpts...)
 		if err != nil {
 			return err
@@ -330,7 +347,7 @@ func (artifactOpts *artifactCmd) runArtifactGet(cmd *cobra.Command, args []strin
 				slog.Int("match count", len(rl.Descriptors)),
 				slog.String("subject", artifactOpts.subject))
 		}
-		r = rSubject.SetDigest(rl.Descriptors[0].Digest.String())
+		r = r.SetDigest(rl.Descriptors[0].Digest.String())
 	} else if len(args) > 0 {
 		var err error
 		r, err = ref.New(args[0])
@@ -587,6 +604,13 @@ func (artifactOpts *artifactCmd) runArtifactList(cmd *cobra.Command, args []stri
 	if artifactOpts.platform != "" {
 		referrerOpts = append(referrerOpts, scheme.WithReferrerPlatform(artifactOpts.platform))
 	}
+	if artifactOpts.externalRepo != "" {
+		rExternal, err := ref.New(artifactOpts.externalRepo)
+		if err != nil {
+			return fmt.Errorf("failed to parse external ref: %w", err)
+		}
+		referrerOpts = append(referrerOpts, scheme.WithReferrerSource(rExternal))
+	}
 
 	rl, err := rc.ReferrerList(ctx, rSubject, referrerOpts...)
 	if err != nil {
@@ -686,8 +710,6 @@ func (artifactOpts *artifactCmd) runArtifactPut(cmd *cobra.Command, args []strin
 	}
 	if !rArt.IsSet() && !rSubject.IsSet() {
 		return fmt.Errorf("either a reference or subject must be provided")
-	} else if rArt.IsSet() && rSubject.IsSet() && !ref.EqualRepository(rArt, rSubject) {
-		return fmt.Errorf("reference and subject must be in the same repository")
 	}
 
 	// validate/set artifactType and config.mediaType
@@ -1040,6 +1062,14 @@ func (artifactOpts *artifactCmd) runArtifactTree(cmd *cobra.Command, args []stri
 		}
 		referrerOpts = append(referrerOpts, scheme.WithReferrerMatchOpt(descriptor.MatchOpt{Annotations: af}))
 	}
+	rRefSrc := r
+	if artifactOpts.externalRepo != "" {
+		rRefSrc, err = ref.New(artifactOpts.externalRepo)
+		if err != nil {
+			return fmt.Errorf("failed to parse external ref: %w", err)
+		}
+		referrerOpts = append(referrerOpts, scheme.WithReferrerSource(rRefSrc))
+	}
 
 	// include digest tags if requested
 	tags := []string{}
@@ -1052,7 +1082,7 @@ func (artifactOpts *artifactCmd) runArtifactTree(cmd *cobra.Command, args []stri
 	}
 
 	seen := []string{}
-	tr, err := artifactOpts.treeAddResult(ctx, rc, r, seen, referrerOpts, tags)
+	tr, err := artifactOpts.treeAddResult(ctx, rc, r, rRefSrc, seen, referrerOpts, tags)
 	var twErr error
 	if tr != nil {
 		twErr = template.Writer(cmd.OutOrStdout(), artifactOpts.formatTree, tr)
@@ -1063,7 +1093,7 @@ func (artifactOpts *artifactCmd) runArtifactTree(cmd *cobra.Command, args []stri
 	return twErr
 }
 
-func (artifactOpts *artifactCmd) treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen []string, rOpts []scheme.ReferrerOpts, tags []string) (*treeResult, error) {
+func (artifactOpts *artifactCmd) treeAddResult(ctx context.Context, rc *regclient.RegClient, r, rRefSrc ref.Ref, seen []string, rOpts []scheme.ReferrerOpts, tags []string) (*treeResult, error) {
 	tr := treeResult{
 		Ref: r,
 	}
@@ -1098,7 +1128,7 @@ func (artifactOpts *artifactCmd) treeAddResult(ctx context.Context, rc *regclien
 		}
 		for _, d := range dl {
 			rChild := r.SetDigest(d.Digest.String())
-			tChild, err := artifactOpts.treeAddResult(ctx, rc, rChild, seen, rOpts, tags)
+			tChild, err := artifactOpts.treeAddResult(ctx, rc, rChild, rRefSrc, seen, rOpts, tags)
 			if tChild != nil {
 				tChild.ArtifactType = d.ArtifactType
 				if d.Platform != nil {
@@ -1121,8 +1151,8 @@ func (artifactOpts *artifactCmd) treeAddResult(ctx context.Context, rc *regclien
 	if len(rl.Descriptors) > 0 {
 		tr.Referrer = []*treeResult{}
 		for _, d := range rl.Descriptors {
-			rReferrer := r.SetDigest(d.Digest.String())
-			tReferrer, err := artifactOpts.treeAddResult(ctx, rc, rReferrer, seen, rOpts, tags)
+			rReferrer := rRefSrc.SetDigest(d.Digest.String())
+			tReferrer, err := artifactOpts.treeAddResult(ctx, rc, rReferrer, rRefSrc, seen, rOpts, tags)
 			if tReferrer != nil {
 				tReferrer.ArtifactType = d.ArtifactType
 				if d.Platform != nil {
@@ -1146,7 +1176,7 @@ func (artifactOpts *artifactCmd) treeAddResult(ctx context.Context, rc *regclien
 		for _, t := range tags {
 			if strings.HasPrefix(t, prefix.Tag) && !sliceHasStr(rl.Tags, t) {
 				rTag := r.SetTag(t)
-				tReferrer, err := artifactOpts.treeAddResult(ctx, rc, rTag, seen, rOpts, tags)
+				tReferrer, err := artifactOpts.treeAddResult(ctx, rc, rTag, rRefSrc, seen, rOpts, tags)
 				if tReferrer != nil {
 					tReferrer.Ref = tReferrer.Ref.SetTag(t)
 					tr.Referrer = append(tr.Referrer, tReferrer)
diff --git a/referrer.go b/referrer.go
index a4411bb..be6a3ea 100644
--- a/referrer.go
+++ b/referrer.go
@@ -6,8 +6,10 @@ import (
 
 	"github.com/regclient/regclient/scheme"
 	"github.com/regclient/regclient/types/errs"
+	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
 	"github.com/regclient/regclient/types/referrer"
+	"github.com/regclient/regclient/types/warning"
 )
 
 // ReferrerList retrieves a list of referrers to a manifest.
@@ -16,6 +18,34 @@ func (rc *RegClient) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme
 	if !r.IsSet() {
 		return referrer.ReferrerList{}, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
 	}
+	// dedup warnings
+	if w := warning.FromContext(ctx); w == nil {
+		ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
+	}
+	// resolve ref to a digest
+	config := scheme.ReferrerConfig{}
+	for _, opt := range opts {
+		opt(&config)
+	}
+	if r.Digest == "" || config.Platform != "" {
+		mo := []ManifestOpts{WithManifestRequireDigest()}
+		if config.Platform != "" {
+			p, err := platform.Parse(config.Platform)
+			if err != nil {
+				return referrer.ReferrerList{}, fmt.Errorf("failed to lookup referrer platform: %w", err)
+			}
+			mo = append(mo, WithManifestPlatform(p))
+		}
+		m, err := rc.ManifestHead(ctx, r, mo...)
+		if err != nil {
+			return referrer.ReferrerList{}, fmt.Errorf("failed to get digest for subject: %w", err)
+		}
+		r = r.SetDigest(m.GetDescriptor().Digest.String())
+	}
+	// update for a new referrer source repo if requested
+	if !config.SrcRepo.IsZero() {
+		r = config.SrcRepo.SetDigest(r.Digest)
+	}
 	schemeAPI, err := rc.schemeGet(r.Scheme)
 	if err != nil {
 		return referrer.ReferrerList{}, err
diff --git a/scheme/ocidir/referrer.go b/scheme/ocidir/referrer.go
index a93862b..3eb1ad6 100644
--- a/scheme/ocidir/referrer.go
+++ b/scheme/ocidir/referrer.go
@@ -10,12 +10,12 @@ import (
 	"github.com/regclient/regclient/types/manifest"
 	"github.com/regclient/regclient/types/mediatype"
 	v1 "github.com/regclient/regclient/types/oci/v1"
-	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
 	"github.com/regclient/regclient/types/referrer"
 )
 
-// ReferrerList returns a list of referrers to a given reference
+// ReferrerList returns a list of referrers to a given reference.
+// The reference must include the digest. Use [regclient.ReferrerList] to resolve the platform or tag.
 func (o *OCIDir) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme.ReferrerOpts) (referrer.ReferrerList, error) {
 	o.mu.Lock()
 	defer o.mu.Unlock()
@@ -30,35 +30,8 @@ func (o *OCIDir) referrerList(ctx context.Context, r ref.Ref, opts ...scheme.Ref
 	rl := referrer.ReferrerList{
 		Tags: []string{},
 	}
-	// select a platform from a manifest list
-	if config.Platform != "" {
-		p, err := platform.Parse(config.Platform)
-		if err != nil {
-			return rl, err
-		}
-		m, err := o.manifestGet(ctx, r)
-		if err != nil {
-			return rl, err
-		}
-		for m.IsList() {
-			d, err := manifest.GetPlatformDesc(m, &p)
-			if err != nil {
-				return rl, err
-			}
-			m, err = o.manifestGet(ctx, r.SetDigest(d.Digest.String()))
-			if err != nil {
-				return rl, err
-			}
-		}
-		r = r.SetDigest(m.GetDescriptor().Digest.String())
-	}
-	// if ref is a tag, run a head request for the digest
 	if r.Digest == "" {
-		m, err := o.manifestGet(ctx, r)
-		if err != nil {
-			return rl, err
-		}
-		r = r.SetDigest(m.GetDescriptor().Digest.String())
+		return rl, fmt.Errorf("digest required to query referrers %s", r.CommonName())
 	}
 	rl.Subject = r
 
diff --git a/scheme/reg/referrer.go b/scheme/reg/referrer.go
index 664e750..66b21be 100644
--- a/scheme/reg/referrer.go
+++ b/scheme/reg/referrer.go
@@ -15,7 +15,6 @@ import (
 	"github.com/regclient/regclient/types/manifest"
 	"github.com/regclient/regclient/types/mediatype"
 	v1 "github.com/regclient/regclient/types/oci/v1"
-	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
 	"github.com/regclient/regclient/types/referrer"
 	"github.com/regclient/regclient/types/warning"
@@ -23,7 +22,8 @@ import (
 
 const OCISubjectHeader = "OCI-Subject"
 
-// ReferrerList returns a list of referrers to a given reference
+// ReferrerList returns a list of referrers to a given reference.
+// The reference must include the digest. Use [regclient.ReferrerList] to resolve the platform or tag.
 func (reg *Reg) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme.ReferrerOpts) (referrer.ReferrerList, error) {
 	config := scheme.ReferrerConfig{}
 	for _, opt := range opts {
@@ -32,65 +32,13 @@ func (reg *Reg) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme.Refe
 	rl := referrer.ReferrerList{
 		Tags: []string{},
 	}
+	if r.Digest == "" {
+		return rl, fmt.Errorf("digest required to query referrers %s", r.CommonName())
+	}
 	// dedup warnings
 	if w := warning.FromContext(ctx); w == nil {
 		ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
 	}
-	// select a platform from a manifest list
-	if config.Platform != "" {
-		p, err := platform.Parse(config.Platform)
-		if err != nil {
-			return rl, err
-		}
-		m, err := reg.ManifestHead(ctx, r)
-		if err != nil {
-			return rl, err
-		}
-		if m.GetDescriptor().Digest.String() == "" {
-			m, err = reg.ManifestGet(ctx, r)
-			if err != nil {
-				return rl, err
-			}
-		}
-		for m.IsList() {
-			m, err = reg.ManifestGet(ctx, r)
-			if err != nil {
-				return rl, err
-			}
-			d, err := manifest.GetPlatformDesc(m, &p)
-			if err != nil {
-				return rl, err
-			}
-			m, err = reg.ManifestHead(ctx, r.SetDigest(d.Digest.String()))
-			if err != nil {
-				return rl, err
-			}
-			if m.GetDescriptor().Digest.String() == "" {
-				m, err = reg.ManifestGet(ctx, r.SetDigest(d.Digest.String()))
-				if err != nil {
-					return rl, err
-				}
-			}
-		}
-		r = r.SetDigest(m.GetDescriptor().Digest.String())
-	}
-	// if ref is a tag, run a head request for the digest
-	if r.Digest == "" {
-		m, err := reg.ManifestHead(ctx, r)
-		if err != nil {
-			return rl, err
-		}
-		if m.GetDescriptor().Digest == "" {
-			m, err = reg.ManifestGet(ctx, r)
-			if err != nil {
-				return rl, err
-			}
-		}
-		if m.GetDescriptor().Digest == "" {
-			return rl, fmt.Errorf("unable to resolve digest for ref %s", r.CommonName())
-		}
-		r = r.SetDigest(m.GetDescriptor().Digest.String())
-	}
 	rl.Subject = r
 
 	found := false
diff --git a/scheme/scheme.go b/scheme/scheme.go
index 0ba063f..13707f5 100644
--- a/scheme/scheme.go
+++ b/scheme/scheme.go
@@ -108,6 +108,7 @@ func WithManifest(m manifest.Manifest) ManifestOpts {
 type ReferrerConfig struct {
 	MatchOpt descriptor.MatchOpt // filter/sort results
 	Platform string              // get referrers for a specific platform
+	SrcRepo  ref.Ref             // repo used to query referrers
 }
 
 // ReferrerOpts is used to set options on referrer APIs.
@@ -121,12 +122,21 @@ func WithReferrerMatchOpt(mo descriptor.MatchOpt) ReferrerOpts {
 }
 
 // WithReferrerPlatform gets referrers for a single platform from a multi-platform manifest.
+// Note that this is implemented by [regclient.ReferrerList] and not the individual scheme implementations.
 func WithReferrerPlatform(p string) ReferrerOpts {
 	return func(config *ReferrerConfig) {
 		config.Platform = p
 	}
 }
 
+// WithReferrerSource pulls referrers from a separate source.
+// Note that this is implemented by [regclient.ReferrerList] and not the individual scheme implementations.
+func WithReferrerSource(r ref.Ref) ReferrerOpts {
+	return func(config *ReferrerConfig) {
+		config.SrcRepo = r
+	}
+}
+
 // WithReferrerAT filters by a specific artifactType value.
 //
 // Deprecated: replace with [WithReferrerMatchOpt].


Test patch:
diff --git a/cmd/regctl/artifact_test.go b/cmd/regctl/artifact_test.go
index 50db3fc..90a5a06 100644
--- a/cmd/regctl/artifact_test.go
+++ b/cmd/regctl/artifact_test.go
@@ -55,6 +55,11 @@ func TestArtifactGet(t *testing.T) {
 			args:      []string{"artifact", "get", "ocidir://../../testdata/testrepo:ai", "--filter-annotation", "type=sbom", "--config"},
 			expectOut: "{}",
 		},
+		{
+			name:      "External",
+			args:      []string{"artifact", "get", "--subject", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom", "--sort-annotation", "preference", "--external", "ocidir://../../testdata/external"},
+			expectOut: "bacon",
+		},
 	}
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
@@ -129,6 +134,12 @@ func TestArtifactList(t *testing.T) {
 			args:      []string{"artifact", "list", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom", "--format", "{{ ( index .Descriptors 0 ).ArtifactType }}"},
 			expectOut: "application/example.sbom",
 		},
+		{
+			name:        "External referrers",
+			args:        []string{"artifact", "list", "ocidir://../../testdata/testrepo:v2", "--external", "ocidir://../../testdata/external"},
+			expectOut:   "Referrers:",
+			outContains: true,
+		},
 	}
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
@@ -220,6 +231,11 @@ func TestArtifactPut(t *testing.T) {
 			args: []string{"artifact", "put", "--artifact-type", "application/vnd.example", "--subject", "ocidir://" + testDir + ":put-example-at"},
 			in:   testData,
 		},
+		{
+			name: "Put external subject",
+			args: []string{"artifact", "put", "--artifact-type", "application/vnd.example", "--subject", "ocidir://" + testDir + ":put-example-at", "ocidir://" + testDir + "/external:external-subj"},
+			in:   testData,
+		},
 		{
 			name: "Put create index",
 			args: []string{"artifact", "put", "--artifact-type", "application/vnd.example", "--annotation", "test=a", "--platform", "linux/amd64", "--index", "ocidir://" + testDir + ":index"},
@@ -331,7 +347,12 @@ func TestArtifactTree(t *testing.T) {
 			args:      []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom", "--format", "{{ ( index .Referrer 0 ).ArtifactType }}"},
 			expectOut: "application/example.sbom",
 		},
-	}
+		{
+			name:        "External referrers",
+			args:        []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2", "--external", "ocidir://../../testdata/external"},
+			expectOut:   "Referrers",
+			outContains: true,
+		}}
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
 			out, err := cobraTest(t, nil, tc.args...)
diff --git a/referrer_test.go b/referrer_test.go
new file mode 100644
index 0000000..936defc
--- /dev/null
+++ b/referrer_test.go
@@ -0,0 +1,195 @@
+package regclient
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"testing"
+
+	"github.com/olareg/olareg"
+	oConfig "github.com/olareg/olareg/config"
+
+	"github.com/regclient/regclient/config"
+	"github.com/regclient/regclient/internal/copyfs"
+	"github.com/regclient/regclient/scheme"
+	"github.com/regclient/regclient/types/descriptor"
+	"github.com/regclient/regclient/types/ref"
+)
+
+func TestReferrerList(t *testing.T) {
+	// setup: copy testdata to tempdir and also start two olareg instances in memory backed by testdata, one with referrers api enabled
+	ctx := context.Background()
+	t.Parallel()
+	testRepo := "testrepo"
+	externalRepo := "external"
+	testTag := "v2"
+	boolT := true
+	boolF := false
+	tempDir := t.TempDir()
+	err := copyfs.Copy(tempDir+"/"+testRepo, "./testdata/"+testRepo)
+	if err != nil {
+		t.Fatalf("failed to copy %s to tempDir: %v", testRepo, err)
+	}
+	err = copyfs.Copy(tempDir+"/"+externalRepo, "./testdata/"+externalRepo)
+	if err != nil {
+		t.Fatalf("failed to copy %s to tempDir: %v", externalRepo, err)
+	}
+	regRefHandler := olareg.New(oConfig.Config{
+		Storage: oConfig.ConfigStorage{
+			StoreType: oConfig.StoreMem,
+			RootDir:   "./testdata",
+		},
+		API: oConfig.ConfigAPI{
+			Referrer: oConfig.ConfigAPIReferrer{
+				Enabled: &boolT,
+			},
+		},
+	})
+	regNoRefHandler := olareg.New(oConfig.Config{
+		Storage: oConfig.ConfigStorage{
+			StoreType: oConfig.StoreMem,
+			RootDir:   "./testdata",
+		},
+		API: oConfig.ConfigAPI{
+			Referrer: oConfig.ConfigAPIReferrer{
+				Enabled: &boolF,
+			},
+		},
+	})
+	tsRef := httptest.NewServer(regRefHandler)
+	tsRefURL, _ := url.Parse(tsRef.URL)
+	tsRefHost := tsRefURL.Host
+	tsNoRef := httptest.NewServer(regNoRefHandler)
+	tsNoRefURL, _ := url.Parse(tsNoRef.URL)
+	tsNoRefHost := tsNoRefURL.Host
+	t.Cleanup(func() {
+		tsRef.Close()
+		tsNoRef.Close()
+		_ = regRefHandler.Close()
+		_ = regNoRefHandler.Close()
+	})
+	rcHosts := []config.Host{
+		{
+			Name:     tsRefHost,
+			Hostname: tsRefHost,
+			TLS:      config.TLSDisabled,
+		},
+		{
+			Name:     tsNoRefHost,
+			Hostname: tsNoRefHost,
+			TLS:      config.TLSDisabled,
+		},
+	}
+	log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	rc := New(
+		WithConfigHost(rcHosts...),
+		WithSlog(log),
+	)
+	// top level table of three registry refs, also include a remote ref for each
+	ttServers := []struct {
+		name     string
+		reg      string
+		external string
+	}{
+		{
+			name:     "ocidir",
+			reg:      "ocidir://" + tempDir,
+			external: tsRefHost,
+		},
+		{
+			name:     "reg-with-referrer",
+			reg:      tsRefHost,
+			external: tsNoRefHost,
+		},
+		{
+			name:     "reg-wo-referrer",
+			reg:      tsNoRefHost,
+			external: "ocidir://" + tempDir,
+		},
+	}
+	for _, tcServer := range ttServers {
+		tcServer := tcServer
+		t.Run(tcServer.name, func(t *testing.T) {
+			t.Parallel()
+			refTag, err := ref.New(fmt.Sprintf("%s/%s:%s", tcServer.reg, testRepo, testTag))
+			if err != nil {
+				t.Fatalf("failed to generate refTag: %v", err)
+			}
+			refExt, err := ref.New(fmt.Sprintf("%s/%s", tcServer.external, externalRepo))
+			if err != nil {
+				t.Fatalf("failed to generate refExt: %v", err)
+			}
+			tt := []struct {
+				name      string
+				ref       ref.Ref
+				opts      []scheme.ReferrerOpts
+				count     int
+				firstAT   string
+				expectErr error
+			}{
+				{
+					name:  "resolve-tag",
+					ref:   refTag,
+					count: 2,
+				},
+				{
+					name: "resolve-platform",
+					ref:  refTag,
+					opts: []scheme.ReferrerOpts{
+						scheme.WithReferrerPlatform("linux/amd64"),
+					},
+					count:   1,
+					firstAT: "application/example.arms",
+				},
+				{
+					name: "filter",
+					ref:  refTag,
+					opts: []scheme.ReferrerOpts{
+						scheme.WithReferrerMatchOpt(descriptor.MatchOpt{ArtifactType: "application/example.signature"}),
+					},
+					count:   1,
+					firstAT: "application/example.signature",
+				},
+				{
+					name: "external-repo",
+					ref:  refTag,
+					opts: []scheme.ReferrerOpts{
+						scheme.WithReferrerSource(refExt),
+						scheme.WithReferrerMatchOpt(descriptor.MatchOpt{SortAnnotation: "preference"}),
+					},
+					count:   2,
+					firstAT: "application/example.sbom",
+				},
+			}
+			for _, tc := range tt {
+				tc := tc
+				t.Run(tc.name, func(t *testing.T) {
+					t.Parallel()
+					rl, err := rc.ReferrerList(ctx, tc.ref, tc.opts...)
+					if tc.expectErr != nil {
+						if err == nil {
+							t.Fatalf("ReferrerList did not fail")
+						}
+						if !errors.Is(err, tc.expectErr) && err.Error() != tc.expectErr.Error() {
+							t.Fatalf("unexpected error, expected %v, received %v", tc.expectErr, err)
+						}
+						return
+					}
+					if err != nil {
+						t.Fatalf("unexpected error: %v", err)
+					}
+					if tc.count != len(rl.Descriptors) {
+						t.Errorf("unexpected number of responses, expected %d, received response %v", tc.count, rl.Descriptors)
+					}
+					if tc.firstAT != "" && (len(rl.Descriptors) == 0 || rl.Descriptors[0].ArtifactType != tc.firstAT) {
+						t.Errorf("unexpected first entry, expected %s, received response %v", tc.firstAT, rl.Descriptors)
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/scheme/ocidir/referrer_test.go b/scheme/ocidir/referrer_test.go
index 60ee6b6..233f79e 100644
--- a/scheme/ocidir/referrer_test.go
+++ b/scheme/ocidir/referrer_test.go
@@ -133,7 +133,7 @@ func TestReferrer(t *testing.T) {
 
 	// list empty
 	t.Run("List empty", func(t *testing.T) {
-		r := mRef
+		r := mRef.SetDigest(mDesc.Digest.String())
 		rl, err := o.ReferrerList(ctx, r)
 		if err != nil {
 			t.Fatalf("Failed running ReferrerList: %v", err)
@@ -166,12 +166,9 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 
-	// list referrers to v1
+	// list referrers after attaching
 	t.Run("List", func(t *testing.T) {
-		r, err := ref.New(repo + ":" + tagName)
-		if err != nil {
-			t.Fatalf("Failed creating getRef: %v", err)
-		}
+		r := mRef.SetDigest(mDesc.Digest.String())
 		rl, err := o.ReferrerList(ctx, r, scheme.WithReferrerMatchOpt(descriptor.MatchOpt{SortAnnotation: timeAnnot}))
 		if err != nil {
 			t.Fatalf("Failed running ReferrerList: %v", err)
@@ -212,10 +209,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List with artifact filter", func(t *testing.T) {
-		r, err := ref.New(repo + ":" + tagName)
-		if err != nil {
-			t.Fatalf("Failed creating getRef: %v", err)
-		}
+		r := mRef.SetDigest(mDesc.Digest.String())
 		rl, err := o.ReferrerList(ctx, r, scheme.WithReferrerMatchOpt(descriptor.MatchOpt{ArtifactType: aType}))
 		if err != nil {
 			t.Fatalf("Failed running ReferrerList: %v", err)
@@ -232,7 +226,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List with annotation filter", func(t *testing.T) {
-		r, err := ref.New(repo + ":" + tagName)
+		r, err := ref.New(repo + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating getRef: %v", err)
 		}
@@ -259,12 +253,12 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	// list platform=linux/amd64
-	t.Run("List Annotation for Platform", func(t *testing.T) {
-		r, err := ref.New(repo + ":" + tagName)
+	t.Run("List Annotation for AMD", func(t *testing.T) {
+		r, err := ref.New(repo + "@" + mAMDDesc.Digest.String())
 		if err != nil {
 			t.Fatalf("Failed creating getRef: %v", err)
 		}
-		rl, err := o.ReferrerList(ctx, r, scheme.WithReferrerPlatform(pAMDStr))
+		rl, err := o.ReferrerList(ctx, r)
 		if err != nil {
 			t.Fatalf("Failed running ReferrerList: %v", err)
 		}
@@ -289,7 +283,7 @@ func TestReferrer(t *testing.T) {
 
 	// list after delete, verify 0 entries
 	t.Run("List empty after delete", func(t *testing.T) {
-		r := mRef
+		r := mRef.SetDigest(mDesc.Digest.String())
 		rl, err := o.ReferrerList(ctx, r)
 		if err != nil {
 			t.Fatalf("Failed running ReferrerList: %v", err)
diff --git a/scheme/reg/referrer_test.go b/scheme/reg/referrer_test.go
index 3021ec4..0b8b91e 100644
--- a/scheme/reg/referrer_test.go
+++ b/scheme/reg/referrer_test.go
@@ -22,7 +22,6 @@ import (
 	"github.com/regclient/regclient/types/manifest"
 	"github.com/regclient/regclient/types/mediatype"
 	v1 "github.com/regclient/regclient/types/oci/v1"
-	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
 )
 
@@ -31,9 +30,6 @@ func TestReferrer(t *testing.T) {
 	// setup http server with and without API support
 	ctx := context.Background()
 	repoPath := "/proj"
-	tagV1 := "v1"
-	tagV1List := "v1-list"
-	tagV1NoHead := "v1-no-head"
 	extraAnnot := "org.example.sbom.format"
 	extraValue := "json"
 	extraValue2 := "x509"
@@ -41,7 +37,6 @@ func TestReferrer(t *testing.T) {
 	digest2 := digest.FromString("example2")
 	configMTA := "application/vnd.example.sbom"
 	configMTB := "application/vnd.example.sig"
-	platStr := "linux/amd64"
 	// manifest being referenced
 	m := schema2.Manifest{
 		Versioned: schema2.ManifestSchemaVersion,
@@ -64,36 +59,6 @@ func TestReferrer(t *testing.T) {
 	}
 	mDigest := digest.FromBytes(mBody)
 	mLen := len(mBody)
-	// manifest list
-	mList := schema2.ManifestList{
-		Versioned: schema2.ManifestListSchemaVersion,
-		Manifests: []descriptor.Descriptor{
-			{
-				MediaType: mediatype.Docker2Manifest,
-				Digest:    mDigest,
-				Size:      int64(mLen),
-				Platform: &platform.Platform{
-					OS:           "linux",
-					Architecture: "amd64",
-				},
-			},
-			{
-				MediaType: mediatype.Docker2Manifest,
-				Digest:    digest.FromString("missing"),
-				Size:      int64(1234),
-				Platform: &platform.Platform{
-					OS:           "linux",
-					Architecture: "arm64",
-				},
-			},
-		},
-	}
-	mlBody, err := json.Marshal(mList)
-	if err != nil {
-		t.Fatalf("Failed to marshal manifest list: %v", err)
-	}
-	mlDigest := digest.FromBytes(mlBody)
-	mlLen := len(mlBody)
 	// artifact being attached
 	artifactAnnot := map[string]string{
 		extraAnnot: extraValue,
@@ -241,97 +206,6 @@ func TestReferrer(t *testing.T) {
 	t.Logf("NoAPI tag: %s\n", tagNoAPI)
 
 	rrs := []reqresp.ReqResp{
-		{
-			ReqEntry: reqresp.ReqEntry{
-				Name:   "Head",
-				Method: "HEAD",
-				Path:   "/v2" + repoPath + "/manifests/" + tagV1,
-			},
-			RespEntry: reqresp.RespEntry{
-				Status: http.StatusOK,
-				Headers: http.Header{
-					"Content-Length":        {fmt.Sprintf("%d", mLen)},
-					"Content-Type":          []string{mediatype.Docker2Manifest},
-					"Docker-Content-Digest": []string{mDigest.String()},
-				},
-			},
-		},
-		{
-			ReqEntry: reqresp.ReqEntry{
-				Name:   "Get",
-				Method: "GET",
-				Path:   "/v2" + repoPath + "/manifests/" + tagV1,
-			},
-			RespEntry: reqresp.RespEntry{
-				Status: http.StatusOK,
-				Headers: http.Header{
-					"Content-Length":        {fmt.Sprintf("%d", mLen)},
-					"Content-Type":          []string{mediatype.Docker2Manifest},
-					"Docker-Content-Digest": []string{mDigest.String()},
-				},
-				Body: mBody,
-			},
-		},
-		{
-			ReqEntry: reqresp.ReqEntry{
-				Name:   "Head",
-				Method: "HEAD",
-				Path:   "/v2" + repoPath + "/manifests/" + tagV1NoHead,
-			},
-			RespEntry: reqresp.RespEntry{
-				Status: http.StatusOK,
-				Headers: http.Header{
-					"Content-Length": {fmt.Sprintf("%d", mLen)},
-					"Content-Type":   []string{mediatype.Docker2Manifest},
-				},
-			},
-		},
-		{
-			ReqEntry: reqresp.ReqEntry{
-				Name:   "Get",
-				Method: "GET",
-				Path:   "/v2" + repoPath + "/manifests/" + tagV1NoHead,
-			},
-			RespEntry: reqresp.RespEntry{
-				Status: http.StatusOK,
-				Headers: http.Header{
-					"Content-Length": {fmt.Sprintf("%d", mLen)},
-					"Content-Type":   []string{mediatype.Docker2Manifest},
-				},
-				Body: mBody,
-			},
-		},
-		{
-			ReqEntry: reqresp.ReqEntry{
-				Name:   "Head",
-				Method: "HEAD",
-				Path:   "/v2" + repoPath + "/manifests/" + tagV1List,
-			},
-			RespEntry: reqresp.RespEntry{
-				Status: http.StatusOK,
-				Headers: http.Header{
-					"Content-Length":        {fmt.Sprintf("%d", mlLen)},
-					"Content-Type":          []string{mediatype.Docker2ManifestList},
-					"Docker-Content-Digest": []string{mlDigest.String()},
-				},
-			},
-		},
-		{
-			ReqEntry: reqresp.ReqEntry{
-				Name:   "Get",
-				Method: "GET",
-				Path:   "/v2" + repoPath + "/manifests/" + tagV1List,
-			},
-			RespEntry: reqresp.RespEntry{
-				Status: http.StatusOK,
-				Headers: http.Header{
-					"Content-Length":        {fmt.Sprintf("%d", mlLen)},
-					"Content-Type":          []string{mediatype.Docker2ManifestList},
-					"Docker-Content-Digest": []string{mlDigest.String()},
-				},
-				Body: mlBody,
-			},
-		},
 		{
 			ReqEntry: reqresp.ReqEntry{
 				Name:   "Head manifest",
@@ -821,7 +695,7 @@ func TestReferrer(t *testing.T) {
 
 	// list empty
 	t.Run("List empty NoAPI", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLNoAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -834,7 +708,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List empty NoAPIAuth", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPIAuth.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLNoAPIAuth.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -847,7 +721,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List empty API", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -894,7 +768,7 @@ func TestReferrer(t *testing.T) {
 
 	// list referrers to v1
 	t.Run("List A NoAPI", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLNoAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -916,7 +790,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List A NoAPIAuth", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPIAuth.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLNoAPIAuth.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -938,7 +812,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List A API", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -960,71 +834,15 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 
-	// list referrers to v1 without digest on head request
-	t.Run("List A NoAPI", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPI.Host + repoPath + ":" + tagV1NoHead)
-		if err != nil {
-			t.Fatalf("Failed creating ref: %v", err)
-		}
-		rl, err := reg.ReferrerList(ctx, r)
-		if err != nil {
-			t.Fatalf("Failed running ReferrerList: %v", err)
-		}
-		if len(rl.Descriptors) < 1 {
-			t.Fatalf("descriptor list missing")
-		}
-		if rl.Descriptors[0].MediaType != mediatype.OCI1Manifest ||
-			rl.Descriptors[0].Size != int64(len(artifactBody)) ||
-			rl.Descriptors[0].Digest != artifactM.GetDescriptor().Digest ||
-			!mapStringStringEq(rl.Descriptors[0].Annotations, artifactAnnot) {
-			t.Errorf("returned descriptor mismatch: %v", rl.Descriptors[0])
-		}
-		if len(rl.Tags) != 1 || rl.Tags[0] != tagNoAPI {
-			t.Errorf("tag list missing entries, received: %v", rl.Tags)
-		}
-	})
-	t.Run("List A NoAPIAuth", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPIAuth.Host + repoPath + ":" + tagV1NoHead)
-		if err != nil {
-			t.Fatalf("Failed creating ref: %v", err)
-		}
-		rl, err := reg.ReferrerList(ctx, r)
-		if err != nil {
-			t.Fatalf("Failed running ReferrerList: %v", err)
-		}
-		if len(rl.Descriptors) < 1 {
-			t.Fatalf("descriptor list missing")
-		}
-		if rl.Descriptors[0].MediaType != mediatype.OCI1Manifest ||
-			rl.Descriptors[0].Size != int64(len(artifactBody)) ||
-			rl.Descriptors[0].Digest != artifactM.GetDescriptor().Digest ||
-			!mapStringStringEq(rl.Descriptors[0].Annotations, artifactAnnot) {
-			t.Errorf("returned descriptor mismatch: %v", rl.Descriptors[0])
-		}
-		if len(rl.Tags) != 1 || rl.Tags[0] != tagNoAPI {
-			t.Errorf("tag list missing entries, received: %v", rl.Tags)
-		}
-	})
-	t.Run("List A API", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1NoHead)
+	// list referrers to v1 without digest
+	t.Run("List A No Digest", func(t *testing.T) {
+		r, err := ref.New(tsURLNoAPI.Host + repoPath + ":v1")
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
-		rl, err := reg.ReferrerList(ctx, r)
-		if err != nil {
-			t.Fatalf("Failed running ReferrerList: %v", err)
-		}
-		if len(rl.Descriptors) < 1 {
-			t.Fatalf("descriptor list missing")
-		}
-		if rl.Descriptors[0].MediaType != mediatype.OCI1Manifest ||
-			rl.Descriptors[0].Size != int64(len(artifactBody)) ||
-			rl.Descriptors[0].Digest != artifactM.GetDescriptor().Digest ||
-			!mapStringStringEq(rl.Descriptors[0].Annotations, artifactAnnot) {
-			t.Errorf("returned descriptor mismatch: %v", rl.Descriptors[0])
-		}
-		if len(rl.Tags) != 0 {
-			t.Errorf("tag list unexpected entries, received: %v", rl.Tags)
+		_, err = reg.ReferrerList(ctx, r)
+		if err == nil {
+			t.Errorf("did not fail when given a tag")
 		}
 	})
 
@@ -1052,7 +870,7 @@ func TestReferrer(t *testing.T) {
 
 	// list referrers to v1
 	t.Run("List Both NoAPI", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLNoAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -1082,7 +900,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List Both API", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -1113,7 +931,7 @@ func TestReferrer(t *testing.T) {
 	})
 
 	t.Run("List with artifact filter API", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -1133,7 +951,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List with annotation filter", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -1160,20 +978,6 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 
-	t.Run("List for platform", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1List)
-		if err != nil {
-			t.Fatalf("Failed creating ref: %v", err)
-		}
-		rl, err := reg.ReferrerList(ctx, r, scheme.WithReferrerPlatform(platStr))
-		if err != nil {
-			t.Fatalf("Failed running ReferrerList: %v", err)
-		}
-		if len(rl.Descriptors) != 2 {
-			t.Fatalf("descriptor list expected 2, received %d", len(rl.Descriptors))
-		}
-	})
-
 	// delete manifest with refers
 	t.Run("Delete B NoAPI", func(t *testing.T) {
 		r, err := ref.New(tsURLNoAPI.Host + repoPath + "@" + artifact2M.GetDescriptor().Digest.String())
@@ -1219,7 +1023,7 @@ func TestReferrer(t *testing.T) {
 
 	// list empty after delete
 	t.Run("List empty after delete NoAPI", func(t *testing.T) {
-		r, err := ref.New(tsURLNoAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLNoAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
@@ -1232,7 +1036,7 @@ func TestReferrer(t *testing.T) {
 		}
 	})
 	t.Run("List empty after delete API", func(t *testing.T) {
-		r, err := ref.New(tsURLAPI.Host + repoPath + ":" + tagV1)
+		r, err := ref.New(tsURLAPI.Host + repoPath + "@" + mDigest.String())
 		if err != nil {
 			t.Fatalf("Failed creating ref: %v", err)
 		}
diff --git a/testdata/build-testrepo.sh b/testdata/build-testrepo.sh
index 0382fdd..1e4e054 100755
--- a/testdata/build-testrepo.sh
+++ b/testdata/build-testrepo.sh
@@ -3,8 +3,8 @@
 set -e
 cd "$(dirname $0)"
 
-# recreate testrepo
-rm -r testrepo
+# recreate testrepo and external
+rm -r testrepo external
 
 # build base images
 for i in 1 2 3; do
@@ -105,6 +105,16 @@ echo 64 arms | regctl artifact put \
   --artifact-type application/example.arms -m application/example.arms \
   --subject ocidir://testrepo:v2 --platform linux/arm64
 
+# include a external artifacts
+echo "bacon" | regctl artifact put \
+  --artifact-type application/example.sbom -m application/example.sbom.breakfast \
+  --annotation preference=1 \
+  --subject ocidir://testrepo:v2 ocidir://external:a3
+echo "ham" | regctl artifact put \
+  --artifact-type application/example.sbom -m application/example.sbom.breakfast \
+  --annotation preference=2 \
+  --subject ocidir://testrepo:v2 ocidir://external:a4
+
 # create an index of artifacts
 regctl index create \
   --artifact-type application/example.artifacts \
diff --git a/testdata/external/blobs/sha256/10da16ba987f09d22f844762c8e8eb012f272c05298e778da8f5c936cece447e b/testdata/external/blobs/sha256/10da16ba987f09d22f844762c8e8eb012f272c05298e778da8f5c936cece447e
new file mode 100644
index 0000000..6e953b2
--- /dev/null
+++ b/testdata/external/blobs/sha256/10da16ba987f09d22f844762c8e8eb012f272c05298e778da8f5c936cece447e
@@ -0,0 +1,1 @@
+bacon
diff --git a/testdata/external/blobs/sha256/30cc0c708132298dd05b03e22eab4ecf99aa0ed0ce9c4904c2b5895707381576 b/testdata/external/blobs/sha256/30cc0c708132298dd05b03e22eab4ecf99aa0ed0ce9c4904c2b5895707381576
new file mode 100644
index 0000000..e68ce37
--- /dev/null
+++ b/testdata/external/blobs/sha256/30cc0c708132298dd05b03e22eab4ecf99aa0ed0ce9c4904c2b5895707381576
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.sbom","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/example.sbom.breakfast","digest":"sha256:10da16ba987f09d22f844762c8e8eb012f272c05298e778da8f5c936cece447e","size":6}],"subject":{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:dfae8f425735a5e3a72e40d6609e03079995511d48157c74d54801ff4430491e","size":934},"annotations":{"preference":"1"}}
\ No newline at end of file
diff --git a/testdata/external/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a b/testdata/external/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/testdata/external/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
@@ -0,0 +1,1 @@
+{}
\ No newline at end of file
diff --git a/testdata/external/blobs/sha256/70a9aadde0b56e4dc849ae4436bebcf019726539bf68143af937e3ab556c5234 b/testdata/external/blobs/sha256/70a9aadde0b56e4dc849ae4436bebcf019726539bf68143af937e3ab556c5234
new file mode 100644
index 0000000..a2fca50
--- /dev/null
+++ b/testdata/external/blobs/sha256/70a9aadde0b56e4dc849ae4436bebcf019726539bf68143af937e3ab556c5234
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:30cc0c708132298dd05b03e22eab4ecf99aa0ed0ce9c4904c2b5895707381576","size":616,"annotations":{"preference":"1"},"artifactType":"application/example.sbom"},{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:c46d306320ae27079061660f74ec841b25d9a423fbaddeebe641b3d66f3e1206","size":616,"annotations":{"preference":"2"},"artifactType":"application/example.sbom"}]}
\ No newline at end of file
diff --git a/testdata/external/blobs/sha256/89a93d620ac3b3db3ada5d0ec425b913b18e591567beee7bc2ed1c09f6fe01d1 b/testdata/external/blobs/sha256/89a93d620ac3b3db3ada5d0ec425b913b18e591567beee7bc2ed1c09f6fe01d1
new file mode 100644
index 0000000..a2d74e0
--- /dev/null
+++ b/testdata/external/blobs/sha256/89a93d620ac3b3db3ada5d0ec425b913b18e591567beee7bc2ed1c09f6fe01d1
@@ -0,0 +1,1 @@
+ham
diff --git a/testdata/external/blobs/sha256/c46d306320ae27079061660f74ec841b25d9a423fbaddeebe641b3d66f3e1206 b/testdata/external/blobs/sha256/c46d306320ae27079061660f74ec841b25d9a423fbaddeebe641b3d66f3e1206
new file mode 100644
index 0000000..eda0078
--- /dev/null
+++ b/testdata/external/blobs/sha256/c46d306320ae27079061660f74ec841b25d9a423fbaddeebe641b3d66f3e1206
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.sbom","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/example.sbom.breakfast","digest":"sha256:89a93d620ac3b3db3ada5d0ec425b913b18e591567beee7bc2ed1c09f6fe01d1","size":4}],"subject":{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:dfae8f425735a5e3a72e40d6609e03079995511d48157c74d54801ff4430491e","size":934},"annotations":{"preference":"2"}}
\ No newline at end of file
diff --git a/testdata/external/index.json b/testdata/external/index.json
new file mode 100644
index 0000000..652ff5b
--- /dev/null
+++ b/testdata/external/index.json
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:30cc0c708132298dd05b03e22eab4ecf99aa0ed0ce9c4904c2b5895707381576","size":616,"annotations":{"org.opencontainers.image.ref.name":"a3"}},{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:70a9aadde0b56e4dc849ae4436bebcf019726539bf68143af937e3ab556c5234","size":543,"annotations":{"org.opencontainers.image.ref.name":"sha256-dfae8f425735a5e3a72e40d6609e03079995511d48157c74d54801ff4430491e"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:c46d306320ae27079061660f74ec841b25d9a423fbaddeebe641b3d66f3e1206","size":616,"annotations":{"org.opencontainers.image.ref.name":"a4"}}]}
\ No newline at end of file
diff --git a/testdata/external/oci-layout b/testdata/external/oci-layout
new file mode 100644
index 0000000..1343d37
--- /dev/null
+++ b/testdata/external/oci-layout
@@ -0,0 +1,1 @@
+{"imageLayoutVersion":"1.0.0"}
\ No newline at end of file