Repository: regclient/regclient
Pull request #400 (2023-03-31 00:49:08)

Add artifactType to image manifest
<!--

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 the the `artifactType` field to the image manifest.
<!-- 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

Artifacts created with `--artifact-type` and no config blob will use a scratch config and the dedicated `artifactType` field.
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Add support for `artifactType` in image manifest
<!-- 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 b4fc725..6abcb58 100644
--- a/cmd/regctl/artifact.go
+++ b/cmd/regctl/artifact.go
@@ -28,9 +28,9 @@ import (
 )
 
 const (
-	ociAnnotTitle   = "org.opencontainers.image.title"
-	defaultMTConfig = "application/vnd.unknown.config+json"
-	defaultMTLayer  = "application/octet-stream"
+	ociAnnotTitle     = "org.opencontainers.image.title"
+	defaultMTArtifact = "application/vnd.unknown.config+json"
+	defaultMTLayer    = "application/octet-stream"
 )
 
 var manifestKnownTypes = []string{
@@ -91,23 +91,24 @@ var artifactTreeCmd = &cobra.Command{
 }
 
 var artifactOpts struct {
-	annotations    []string
-	artifactMT     string
-	artifactType   string
-	artifactConfig string
-	artifactFile   []string
-	artifactFileMT []string
-	byDigest       bool
-	filterAT       string
-	filterAnnot    []string
-	formatList     string
-	formatPut      string
-	formatTree     string
-	outputDir      string
-	platform       string
-	refers         string
-	stripDirs      bool
-	subject        string
+	annotations      []string
+	artifactMT       string
+	artifactType     string
+	artifactConfig   string
+	artifactConfigMT string
+	artifactFile     []string
+	artifactFileMT   []string
+	byDigest         bool
+	filterAT         string
+	filterAnnot      []string
+	formatList       string
+	formatPut        string
+	formatTree       string
+	outputDir        string
+	platform         string
+	refers           string
+	stripDirs        bool
+	subject          string
 }
 
 func init() {
@@ -136,11 +137,13 @@ func init() {
 		return manifestKnownTypes, cobra.ShellCompDirectiveNoFileComp
 	})
 	artifactPutCmd.Flags().MarkHidden("media-type")
-	artifactPutCmd.Flags().StringVarP(&artifactOpts.artifactType, "artifact-type", "", "", "Artifact type or config mediaType")
-	artifactPutCmd.RegisterFlagCompletionFunc("artifact-type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+	artifactPutCmd.Flags().StringVarP(&artifactOpts.artifactType, "artifact-type", "", "", "Artifact type (recommended)")
+	artifactPutCmd.RegisterFlagCompletionFunc("artifact-type", completeArgNone)
+	artifactPutCmd.Flags().StringVarP(&artifactOpts.artifactConfig, "config-file", "", "", "Filename for config content")
+	artifactPutCmd.Flags().StringVarP(&artifactOpts.artifactConfigMT, "config-type", "", "", "Config mediaType")
+	artifactPutCmd.RegisterFlagCompletionFunc("config-type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
 		return configKnownTypes, cobra.ShellCompDirectiveNoFileComp
 	})
-	artifactPutCmd.Flags().StringVarP(&artifactOpts.artifactConfig, "config-file", "", "", "Filename for config content")
 	artifactPutCmd.Flags().StringArrayVarP(&artifactOpts.artifactFile, "file", "f", []string{}, "Artifact filename")
 	artifactPutCmd.Flags().StringArrayVarP(&artifactOpts.artifactFileMT, "file-media-type", "m", []string{}, "Set the mediaType for the individual files")
 	artifactPutCmd.RegisterFlagCompletionFunc("file-media-type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
@@ -489,6 +492,35 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 	} else if !rArt.IsZero() && !rSubject.IsZero() && !ref.EqualRepository(rArt, rSubject) {
 		return fmt.Errorf("reference and subject must be in the same repository")
 	}
+
+	// validate/set artifactType and config.mediaType
+	if hasConfig && artifactOpts.artifactConfigMT == "" {
+		if artifactOpts.artifactConfig == "" {
+			artifactOpts.artifactConfigMT = types.MediaTypeOCI1Scratch
+			if artifactOpts.artifactType == "" {
+				log.Warnf("using default value for artifact-type is not recommended")
+				artifactOpts.artifactType = defaultMTArtifact
+			}
+		} else {
+			if artifactOpts.artifactType != "" {
+				artifactOpts.artifactConfigMT = artifactOpts.artifactType
+				artifactOpts.artifactType = ""
+				log.Warnf("setting config-type using artifact-type is deprecated")
+			} else {
+				return fmt.Errorf("config-type is required for config-file")
+			}
+		}
+	} else if !hasConfig {
+		if artifactOpts.artifactConfig != "" || artifactOpts.artifactConfigMT != "" {
+			return fmt.Errorf("cannot set config-type or config-file on %s%.0w", artifactOpts.artifactMT, types.ErrUnsupportedMediaType)
+		}
+		if artifactOpts.artifactType == "" {
+			log.Warnf("using default value for artifact-type is not recommended")
+			artifactOpts.artifactType = defaultMTArtifact
+		}
+	}
+
+	// validate artifact files with media types
 	if len(artifactOpts.artifactFile) == 1 && len(artifactOpts.artifactFileMT) == 0 {
 		// default media-type for a single file, same is used for stdin
 		artifactOpts.artifactFileMT = []string{defaultMTLayer}
@@ -498,9 +530,6 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 		// all other mis-matches are invalid
 		return fmt.Errorf("one artifact media-type must be set for each artifact file")
 	}
-	if artifactOpts.artifactType == "" && hasConfig {
-		artifactOpts.artifactType = defaultMTConfig
-	}
 
 	// include annotations
 	annotations := map[string]string{}
@@ -546,15 +575,19 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 	// read config, or initialize to an empty json config
 	confDesc := types.Descriptor{}
 	if hasConfig {
-		configBytes := []byte("{}")
-		if artifactOpts.artifactConfig != "" {
+		var configBytes []byte
+		var configDigest digest.Digest
+		if artifactOpts.artifactConfig == "" {
+			configBytes = types.ScratchData
+			configDigest = types.ScratchDigest
+		} else {
 			var err error
 			configBytes, err = os.ReadFile(artifactOpts.artifactConfig)
 			if err != nil {
 				return err
 			}
+			configDigest = digest.FromBytes(configBytes)
 		}
-		configDigest := digest.FromBytes(configBytes)
 		// push config to registry
 		_, err = rc.BlobPut(ctx, r, types.Descriptor{Digest: configDigest, Size: int64(len(configBytes))}, bytes.NewReader(configBytes))
 		if err != nil {
@@ -562,12 +595,10 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 		}
 		// save config descriptor to manifest
 		confDesc = types.Descriptor{
-			MediaType: artifactOpts.artifactType,
+			MediaType: artifactOpts.artifactConfigMT,
 			Digest:    configDigest,
 			Size:      int64(len(configBytes)),
 		}
-	} else if artifactOpts.artifactConfig != "" {
-		return fmt.Errorf("config is not supported with media type %s", artifactOpts.artifactMT)
 	}
 
 	blobs := []types.Descriptor{}
@@ -658,7 +689,7 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 		if len(artifactOpts.artifactFileMT) > 0 {
 			mt = artifactOpts.artifactFileMT[0]
 		}
-		d, err := rc.BlobPut(ctx, r, types.Descriptor{}, os.Stdin)
+		d, err := rc.BlobPut(ctx, r, types.Descriptor{}, cmd.InOrStdin())
 		if err != nil {
 			return err
 		}
@@ -666,7 +697,8 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 		blobs = append(blobs, d)
 	}
 
-	if artifactOpts.artifactMT == types.MediaTypeOCI1Artifact {
+	switch artifactOpts.artifactMT {
+	case types.MediaTypeOCI1Artifact:
 		m := v1.ArtifactManifest{
 			MediaType:    types.MediaTypeOCI1Artifact,
 			ArtifactType: artifactOpts.artifactType,
@@ -675,16 +707,19 @@ func runArtifactPut(cmd *cobra.Command, args []string) error {
 			Subject:      subjectDesc,
 		}
 		mOpts = append(mOpts, manifest.WithOrig(m))
-	} else {
+	case "", types.MediaTypeOCI1Manifest:
 		m := v1.Manifest{
-			Versioned:   v1.ManifestSchemaVersion,
-			MediaType:   types.MediaTypeOCI1Manifest,
-			Config:      confDesc,
-			Layers:      blobs,
-			Annotations: annotations,
-			Subject:     subjectDesc,
+			Versioned:    v1.ManifestSchemaVersion,
+			MediaType:    types.MediaTypeOCI1Manifest,
+			ArtifactType: artifactOpts.artifactType,
+			Config:       confDesc,
+			Layers:       blobs,
+			Annotations:  annotations,
+			Subject:      subjectDesc,
 		}
 		mOpts = append(mOpts, manifest.WithOrig(m))
+	default:
+		return fmt.Errorf("unsupported manifest media type: %s", artifactOpts.artifactMT)
 	}
 
 	// generate manifest
diff --git a/mod/manifest.go b/mod/manifest.go
index a1659f5..5c874d7 100644
--- a/mod/manifest.go
+++ b/mod/manifest.go
@@ -229,6 +229,9 @@ func WithManifestToDocker() Opts {
 				if dm.m.GetDescriptor().MediaType != types.MediaTypeDocker2Manifest {
 					changed = true
 				}
+				if ociM.ArtifactType != "" {
+					return fmt.Errorf("unable to convert artifactType to docker manifest, ref %s%.0w", r.CommonName(), types.ErrUnsupportedMediaType)
+				}
 				if ociM.Config.MediaType == types.MediaTypeOCI1ImageConfig {
 					ociM.Config.MediaType = types.MediaTypeDocker2ImageConfig
 					changed = true
diff --git a/types/manifest/oci1.go b/types/manifest/oci1.go
index 121f07e..2209d2c 100644
--- a/types/manifest/oci1.go
+++ b/types/manifest/oci1.go
@@ -215,6 +215,9 @@ func (m *oci1Manifest) MarshalPretty() ([]byte, error) {
 		fmt.Fprintf(tw, "Name:\t%s\n", m.r.Reference)
 	}
 	fmt.Fprintf(tw, "MediaType:\t%s\n", m.desc.MediaType)
+	if m.ArtifactType != "" {
+		fmt.Fprintf(tw, "ArtifactType:\t%s\n", m.ArtifactType)
+	}
 	fmt.Fprintf(tw, "Digest:\t%s\n", m.desc.Digest.String())
 	if m.Annotations != nil && len(m.Annotations) > 0 {
 		fmt.Fprintf(tw, "Annotations:\t\n")
@@ -309,6 +312,7 @@ func (m *oci1Artifact) MarshalPretty() ([]byte, error) {
 		fmt.Fprintf(tw, "Name:\t%s\n", m.r.Reference)
 	}
 	fmt.Fprintf(tw, "MediaType:\t%s\n", m.desc.MediaType)
+	fmt.Fprintf(tw, "ArtifactType:\t%s\n", m.ArtifactType)
 	fmt.Fprintf(tw, "Digest:\t%s\n", m.desc.Digest.String())
 	if m.Annotations != nil && len(m.Annotations) > 0 {
 		fmt.Fprintf(tw, "Annotations:\t\n")
diff --git a/types/mediatype.go b/types/mediatype.go
index 403ff48..27595a1 100644
--- a/types/mediatype.go
+++ b/types/mediatype.go
@@ -35,6 +35,8 @@ const (
 	MediaTypeOCI1ForeignLayerGzip = "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"
 	// MediaTypeOCI1ForeignLayerZstd is the zstd compressed foreign layer for OCI v1
 	MediaTypeOCI1ForeignLayerZstd = "application/vnd.oci.image.layer.nondistributable.v1.tar+zstd"
+	// MediaTypeOCI1Scratch is used for blobs containing the scratch data `{}`
+	MediaTypeOCI1Scratch = "application/vnd.oci.scratch.v1+json"
 	// MediaTypeBuildkitCacheConfig is used by buildkit cache images
 	MediaTypeBuildkitCacheConfig = "application/vnd.buildkit.cacheconfig.v0"
 )
diff --git a/types/oci/v1/manifest.go b/types/oci/v1/manifest.go
index 53476ff..965a809 100644
--- a/types/oci/v1/manifest.go
+++ b/types/oci/v1/manifest.go
@@ -17,6 +17,9 @@ type Manifest struct {
 	// MediaType specifies the type of this document data structure e.g. `application/vnd.oci.image.manifest.v1+json`
 	MediaType string `json:"mediaType,omitempty"`
 
+	// ArtifactType specifies the IANA media type of artifact when the manifest is used for an artifact.
+	ArtifactType string `json:"artifactType,omitempty"`
+
 	// Config references a configuration object for a container, by digest.
 	// The referenced configuration object is a JSON blob that the runtime uses to set up the container.
 	Config types.Descriptor `json:"config"`
diff --git a/types/referrer/referrer.go b/types/referrer/referrer.go
index 9993756..e0e694c 100644
--- a/types/referrer/referrer.go
+++ b/types/referrer/referrer.go
@@ -43,13 +43,18 @@ func (rl *ReferrerList) Add(m manifest.Manifest) error {
 		mDesc.ArtifactType = mOrig.ArtifactType
 	case v1.Manifest:
 		mDesc.Annotations = mOrig.Annotations
-		mDesc.ArtifactType = mOrig.Config.MediaType
+		if mOrig.ArtifactType != "" {
+			mDesc.ArtifactType = mOrig.ArtifactType
+		} else {
+			mDesc.ArtifactType = mOrig.Config.MediaType
+		}
 	default:
 		// other types are not supported
 		return fmt.Errorf("invalid manifest for referrer \"%t\": %w", m.GetOrig(), types.ErrUnsupportedMediaType)
 	}
 	// append descriptor to index
 	rlM.Manifests = append(rlM.Manifests, mDesc)
+	rl.Descriptors = rlM.Manifests
 	err := rl.Manifest.SetOrig(rlM)
 	if err != nil {
 		return err
@@ -79,6 +84,7 @@ func (rl *ReferrerList) Delete(m manifest.Manifest) error {
 	if !found {
 		return fmt.Errorf("subject not found in referrer list%.0w", types.ErrNotFound)
 	}
+	rl.Descriptors = rlM.Manifests
 	err := rl.Manifest.SetOrig(rlM)
 	if err != nil {
 		return err


Test patch:
diff --git a/cmd/regctl/artifact_test.go b/cmd/regctl/artifact_test.go
index 670a6f2..2322b3e 100644
--- a/cmd/regctl/artifact_test.go
+++ b/cmd/regctl/artifact_test.go
@@ -1,14 +1,101 @@
 package main
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
+	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 
 	"github.com/regclient/regclient/types"
 )
 
+func TestArtifactPut(t *testing.T) {
+	testDir := t.TempDir()
+	testData := []byte("hello world")
+	testConfName := filepath.Join(testDir, "exConf")
+	err := os.WriteFile(testConfName, []byte(`{"hello": "world"}`), 0600)
+	if err != nil {
+		t.Errorf("failed creating test conf: %v", err)
+		return
+	}
+
+	tt := []struct {
+		name        string
+		args        []string
+		in          []byte
+		expectErr   error
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "Missing arg",
+			args:      []string{"artifact", "put"},
+			expectErr: fmt.Errorf("either a reference or subject must be provided"),
+		},
+		{
+			name:      "Invalid ref",
+			args:      []string{"artifact", "put", "invalid*ref"},
+			expectErr: fmt.Errorf("invalid reference \"%s\"", "invalid*ref"),
+		},
+		{
+			name: "Put artifact",
+			args: []string{"artifact", "put", "ocidir://" + testDir + ":put"},
+			in:   testData,
+		},
+		{
+			name: "Put artifact example AT",
+			args: []string{"artifact", "put", "--artifact-type", "application/vnd.example", "ocidir://" + testDir + ":put-example-at"},
+			in:   testData,
+		},
+		{
+			name: "Put artifact example conf",
+			args: []string{"artifact", "put", "--config-type", "application/vnd.example", "ocidir://" + testDir + ":put-example-conf"},
+			in:   testData,
+		},
+		{
+			name: "Put artifact example conf data",
+			args: []string{"artifact", "put", "--artifact-type", "", "--config-type", "application/vnd.example", "--config-file", testConfName, "ocidir://" + testDir + ":put-example-conf-data"},
+			in:   testData,
+		},
+		{
+			name: "Put subject",
+			args: []string{"artifact", "put", "--artifact-type", "application/vnd.example", "--config-file", "", "--subject", "ocidir://" + testDir + ":put-example-at"},
+			in:   testData,
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.in != nil {
+				origIn := rootCmd.InOrStdin()
+				defer rootCmd.SetIn(origIn)
+				rootCmd.SetIn(bytes.NewBuffer(tc.in))
+			}
+			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)
+			}
+		})
+	}
+	// reset flags
+	artifactOpts.artifactConfig = ""
+	artifactOpts.subject = ""
+}
+
 func TestArtifactTree(t *testing.T) {
 	tt := []struct {
 		name        string
@@ -76,5 +163,4 @@ func TestArtifactTree(t *testing.T) {
 			}
 		})
 	}
-
 }
diff --git a/cmd/regsync/regsync_test.go b/cmd/regsync/regsync_test.go
index 9ddd079..dcb738a 100644
--- a/cmd/regsync/regsync_test.go
+++ b/cmd/regsync/regsync_test.go
@@ -104,8 +104,8 @@ func TestRegsyncOnce(t *testing.T) {
 				"test1/blobs/sha256/5283ed7b662424a7f9edc47f8e0e266d47f8ce997da51949d454b30eaafb5251", // amd64
 				"test1/blobs/sha256/3f4eb4d2ca4fe85d3da97aab1a56422cb4a05334274a2e275cf848db90a41b18",
 				"test1/blobs/sha256/a7f0466d930515f984dc334bf786a569973119a3afaa2d4290f2268c62a19b12", // arm64
-				"test1/blobs/sha256/fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9", // v2 referrer sbom
-				"test1/blobs/sha256/b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4", // v2 referrer sig
+				"test1/blobs/sha256/798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68", // v2 referrer sbom
+				"test1/blobs/sha256/557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f", // v2 referrer sig
 			},
 			expErr: nil,
 		},
@@ -188,8 +188,8 @@ func TestRegsyncOnce(t *testing.T) {
 				"test-referrer/oci-layout",
 				"test-referrer/blobs/sha256/adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8", // v2
 				"test-referrer/blobs/sha256/ef05efc8cfd478ac3140fce1297bd6b72dc5f5f1df31bfce690aa903a2c20310", // v2 amd64
-				"test-referrer/blobs/sha256/fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9", // v2 sbom
-				"test-referrer/blobs/sha256/b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4", // v2 sig
+				"test-referrer/blobs/sha256/798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68", // v2 sbom
+				"test-referrer/blobs/sha256/557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f", // v2 sig
 			},
 			undesired: []string{
 				"test-referrer/blobs/sha256/4a88e72dd0e4245e6ecfbc6faae751eeeff82861f9ef39634bea07d77dbb1f40", // v1
@@ -216,11 +216,11 @@ func TestRegsyncOnce(t *testing.T) {
 				"test-referrer2/oci-layout",
 				"test-referrer2/blobs/sha256/adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8", // v2
 				"test-referrer2/blobs/sha256/ef05efc8cfd478ac3140fce1297bd6b72dc5f5f1df31bfce690aa903a2c20310", // v2 amd64
-				"test-referrer2/blobs/sha256/fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9", // v2 sbom
+				"test-referrer2/blobs/sha256/798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68", // v2 sbom
 			},
 			undesired: []string{
 				"test-referrer2/blobs/sha256/4a88e72dd0e4245e6ecfbc6faae751eeeff82861f9ef39634bea07d77dbb1f40", // v1
-				"test-referrer2/blobs/sha256/b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4", // v2 sig
+				"test-referrer2/blobs/sha256/557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f", // v2 sig
 				"test-referrer2/blobs/sha256/2e024d7fe67394cef7f0df9c303f288c7420d1fb27f7657398e897b7eb2ee1d8", // v3 + meta digest tag
 			},
 			expErr: nil,
diff --git a/testdata/build-testrepo.sh b/testdata/build-testrepo.sh
index d7d217e..246b448 100755
--- a/testdata/build-testrepo.sh
+++ b/testdata/build-testrepo.sh
@@ -69,7 +69,7 @@ done
 # create a docker artifact on v1
 echo scripted build | regctl artifact put \
   --media-type application/vnd.oci.image.manifest.v1+json \
-  --artifact-type application/vnd.oci.image.config.v1+json \
+  --config-type application/vnd.oci.image.config.v1+json \
   --file-media-type application/vnd.example.build-type+json \
   ocidir://testrepo:a-docker
 regctl index add \
diff --git a/testdata/testrepo/blobs/sha256/036ad24101294bf3306068c3d8a04754f79385550fbd83b2cc2bc59afec763fd b/testdata/testrepo/blobs/sha256/036ad24101294bf3306068c3d8a04754f79385550fbd83b2cc2bc59afec763fd
new file mode 100644
index 0000000..a7193c9
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/036ad24101294bf3306068c3d8a04754f79385550fbd83b2cc2bc59afec763fd
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":578,"digest":"sha256:2b7a146c97151d54a03f03768d15e20364391ab54cc72202c13bba2cb1a5d7ab","artifactType":"application/example.arms"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/2b7a146c97151d54a03f03768d15e20364391ab54cc72202c13bba2cb1a5d7ab b/testdata/testrepo/blobs/sha256/2b7a146c97151d54a03f03768d15e20364391ab54cc72202c13bba2cb1a5d7ab
new file mode 100644
index 0000000..a0b7ef0
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/2b7a146c97151d54a03f03768d15e20364391ab54cc72202c13bba2cb1a5d7ab
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.arms","config":{"mediaType":"application/vnd.oci.scratch.v1+json","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.arms","size":8,"digest":"sha256:ba8916eda90c32a3d2ae88b881bff496dcb02637801567def81586e0298e57a2"}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":710,"digest":"sha256:ef05efc8cfd478ac3140fce1297bd6b72dc5f5f1df31bfce690aa903a2c20310"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/33616cd16da48b19f44b64704fde97c836bfb3aec4f857dcc6c359f736f62914 b/testdata/testrepo/blobs/sha256/33616cd16da48b19f44b64704fde97c836bfb3aec4f857dcc6c359f736f62914
new file mode 100644
index 0000000..e26c3bd
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/33616cd16da48b19f44b64704fde97c836bfb3aec4f857dcc6c359f736f62914
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":585,"digest":"sha256:798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68","artifactType":"application/example.sbom"},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":590,"digest":"sha256:557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f","artifactType":"application/example.signature"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/3c5862a104233f94c9b37ff0fbeba5adf26a9d1f6118fe6ee4f9b9e33a2e7a31 b/testdata/testrepo/blobs/sha256/3c5862a104233f94c9b37ff0fbeba5adf26a9d1f6118fe6ee4f9b9e33a2e7a31
deleted file mode 100644
index 754bef2..0000000
--- a/testdata/testrepo/blobs/sha256/3c5862a104233f94c9b37ff0fbeba5adf26a9d1f6118fe6ee4f9b9e33a2e7a31
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/example.arms","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.arms","size":8,"digest":"sha256:32e85d5810eaa7fbec506d7ce5613d9aca0732b04b49a832764bb817022477ea"}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":710,"digest":"sha256:56ed84e8d4f983411218751bcb5d544e14e28d210101a12c63a1be03108de01b"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/3c586eae9d15dcf39df07fc66fbee374fbe3fbf5f8c0b8b04bc9d7416973fa0a b/testdata/testrepo/blobs/sha256/3c586eae9d15dcf39df07fc66fbee374fbe3fbf5f8c0b8b04bc9d7416973fa0a
new file mode 100644
index 0000000..ca02bc8
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/3c586eae9d15dcf39df07fc66fbee374fbe3fbf5f8c0b8b04bc9d7416973fa0a
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.arms","config":{"mediaType":"application/vnd.oci.scratch.v1+json","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.arms","size":7,"digest":"sha256:30d87ac83139c4a1d853180cbb407e72baea5383e8113d7bef99ef35feb3c5c1"}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":710,"digest":"sha256:5da622c67d8d1b2138493a79ec39250e784db584e2561bf0ab78c1e20b7962cf"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/3ed6a2dc17e22660b7c5b19a007380f44a2ebc8c3e1269e934fd7e3688062042 b/testdata/testrepo/blobs/sha256/3ed6a2dc17e22660b7c5b19a007380f44a2ebc8c3e1269e934fd7e3688062042
new file mode 100644
index 0000000..35339aa
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/3ed6a2dc17e22660b7c5b19a007380f44a2ebc8c3e1269e934fd7e3688062042
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":578,"digest":"sha256:96ab07dcc3f4845046a76d5b2115788180d9cfa1a2bd06a43c76804f387ab164","artifactType":"application/example.arms"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/435bde2f7bea8501968fe3a9717b2d43eb7cb366a75ce2ca31fea0d7052c059c b/testdata/testrepo/blobs/sha256/435bde2f7bea8501968fe3a9717b2d43eb7cb366a75ce2ca31fea0d7052c059c
deleted file mode 100644
index dd7623d..0000000
--- a/testdata/testrepo/blobs/sha256/435bde2f7bea8501968fe3a9717b2d43eb7cb366a75ce2ca31fea0d7052c059c
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":525,"digest":"sha256:6d8412d04fda16957bba600832e840ff144cb0f2c70bb14b8799d33fbb15e6a3","artifactType":"application/example.arms"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/468aaf700a290fd1f9d7f2236354c8d9635e4b5ff9c08b8ece722c3b20a653d0 b/testdata/testrepo/blobs/sha256/468aaf700a290fd1f9d7f2236354c8d9635e4b5ff9c08b8ece722c3b20a653d0
deleted file mode 100644
index 8e57e24..0000000
--- a/testdata/testrepo/blobs/sha256/468aaf700a290fd1f9d7f2236354c8d9635e4b5ff9c08b8ece722c3b20a653d0
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":525,"digest":"sha256:7c759eba792dfa6a355a7e944e995add9e50de2c6401edf8d36d9b74ca1b9bde","artifactType":"application/example.arms"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f b/testdata/testrepo/blobs/sha256/557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f
new file mode 100644
index 0000000..210fc61
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.signature","config":{"mediaType":"application/vnd.oci.scratch.v1+json","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.signature.text","size":7,"digest":"sha256:ad093de033f89b590459503f94d25ea4a36fd044f7ce6d674f43e0d8412161f9"}],"subject":{"mediaType":"application/vnd.oci.image.index.v1+json","size":922,"digest":"sha256:adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/6d8412d04fda16957bba600832e840ff144cb0f2c70bb14b8799d33fbb15e6a3 b/testdata/testrepo/blobs/sha256/6d8412d04fda16957bba600832e840ff144cb0f2c70bb14b8799d33fbb15e6a3
deleted file mode 100644
index 7fdccad..0000000
--- a/testdata/testrepo/blobs/sha256/6d8412d04fda16957bba600832e840ff144cb0f2c70bb14b8799d33fbb15e6a3
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/example.arms","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.arms","size":7,"digest":"sha256:30d87ac83139c4a1d853180cbb407e72baea5383e8113d7bef99ef35feb3c5c1"}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":710,"digest":"sha256:5da622c67d8d1b2138493a79ec39250e784db584e2561bf0ab78c1e20b7962cf"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68 b/testdata/testrepo/blobs/sha256/798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68
new file mode 100644
index 0000000..4bc0b57
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.sbom","config":{"mediaType":"application/vnd.oci.scratch.v1+json","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.sbom.breakfast","size":5,"digest":"sha256:e9c3c1c06f1825ffa801eac2930fc97e8cecf63d41c7f5d92a8bb21d7ed288bc"}],"subject":{"mediaType":"application/vnd.oci.image.index.v1+json","size":922,"digest":"sha256:adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/7c759eba792dfa6a355a7e944e995add9e50de2c6401edf8d36d9b74ca1b9bde b/testdata/testrepo/blobs/sha256/7c759eba792dfa6a355a7e944e995add9e50de2c6401edf8d36d9b74ca1b9bde
deleted file mode 100644
index bb2bee1..0000000
--- a/testdata/testrepo/blobs/sha256/7c759eba792dfa6a355a7e944e995add9e50de2c6401edf8d36d9b74ca1b9bde
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/example.arms","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.arms","size":8,"digest":"sha256:ba8916eda90c32a3d2ae88b881bff496dcb02637801567def81586e0298e57a2"}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":710,"digest":"sha256:ef05efc8cfd478ac3140fce1297bd6b72dc5f5f1df31bfce690aa903a2c20310"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/96ab07dcc3f4845046a76d5b2115788180d9cfa1a2bd06a43c76804f387ab164 b/testdata/testrepo/blobs/sha256/96ab07dcc3f4845046a76d5b2115788180d9cfa1a2bd06a43c76804f387ab164
new file mode 100644
index 0000000..058894c
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/96ab07dcc3f4845046a76d5b2115788180d9cfa1a2bd06a43c76804f387ab164
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/example.arms","config":{"mediaType":"application/vnd.oci.scratch.v1+json","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.arms","size":8,"digest":"sha256:32e85d5810eaa7fbec506d7ce5613d9aca0732b04b49a832764bb817022477ea"}],"subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":710,"digest":"sha256:56ed84e8d4f983411218751bcb5d544e14e28d210101a12c63a1be03108de01b"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/9858e0889fb2a74c261dc732c2aec004aa3020b15b92d68c80cd6225b90b5de2 b/testdata/testrepo/blobs/sha256/9858e0889fb2a74c261dc732c2aec004aa3020b15b92d68c80cd6225b90b5de2
deleted file mode 100644
index e88a7b4..0000000
--- a/testdata/testrepo/blobs/sha256/9858e0889fb2a74c261dc732c2aec004aa3020b15b92d68c80cd6225b90b5de2
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":525,"digest":"sha256:3c5862a104233f94c9b37ff0fbeba5adf26a9d1f6118fe6ee4f9b9e33a2e7a31","artifactType":"application/example.arms"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/a3192fd5d2b7571dd8dfb7bab6c348986a5daf6439c7464a1867be602a50a070 b/testdata/testrepo/blobs/sha256/a3192fd5d2b7571dd8dfb7bab6c348986a5daf6439c7464a1867be602a50a070
new file mode 100644
index 0000000..5c17c16
--- /dev/null
+++ b/testdata/testrepo/blobs/sha256/a3192fd5d2b7571dd8dfb7bab6c348986a5daf6439c7464a1867be602a50a070
@@ -0,0 +1,1 @@
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":578,"digest":"sha256:3c586eae9d15dcf39df07fc66fbee374fbe3fbf5f8c0b8b04bc9d7416973fa0a","artifactType":"application/example.arms"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/a39a515fb72925723bb37fb46a7ff893444cfb22defa65646d5e15cf006fcbf3 b/testdata/testrepo/blobs/sha256/a39a515fb72925723bb37fb46a7ff893444cfb22defa65646d5e15cf006fcbf3
deleted file mode 100644
index c73b608..0000000
--- a/testdata/testrepo/blobs/sha256/a39a515fb72925723bb37fb46a7ff893444cfb22defa65646d5e15cf006fcbf3
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":532,"digest":"sha256:fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9","artifactType":"application/example.sbom"},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":537,"digest":"sha256:b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4","artifactType":"application/example.signature"}]}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4 b/testdata/testrepo/blobs/sha256/b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4
deleted file mode 100644
index 324ec2e..0000000
--- a/testdata/testrepo/blobs/sha256/b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/example.signature","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.signature.text","size":7,"digest":"sha256:ad093de033f89b590459503f94d25ea4a36fd044f7ce6d674f43e0d8412161f9"}],"subject":{"mediaType":"application/vnd.oci.image.index.v1+json","size":922,"digest":"sha256:adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8"}}
\ No newline at end of file
diff --git a/testdata/testrepo/blobs/sha256/fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9 b/testdata/testrepo/blobs/sha256/fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9
deleted file mode 100644
index 22e77a1..0000000
--- a/testdata/testrepo/blobs/sha256/fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9
+++ /dev/null
@@ -1,1 +0,0 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/example.sbom","size":2,"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},"layers":[{"mediaType":"application/example.sbom.breakfast","size":5,"digest":"sha256:e9c3c1c06f1825ffa801eac2930fc97e8cecf63d41c7f5d92a8bb21d7ed288bc"}],"subject":{"mediaType":"application/vnd.oci.image.index.v1+json","size":922,"digest":"sha256:adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8"}}
\ No newline at end of file
diff --git a/testdata/testrepo/index.json b/testdata/testrepo/index.json
index 3b71e7a..0b748fb 100644
--- a/testdata/testrepo/index.json
+++ b/testdata/testrepo/index.json
@@ -1,1 +1,1 @@
-{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","size":964,"digest":"sha256:f91e00c1c151c75243a87073297efc68b8fe83a3dd82142ef6fb9f51dddb6a37","annotations":{"org.opencontainers.image.ref.name":"b1"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":964,"digest":"sha256:0a71ca431251e73ab58c6bbfa3d3343b9c0d3334aa6554052ee886711b15c93e","annotations":{"org.opencontainers.image.ref.name":"b2"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":964,"digest":"sha256:84078061572beea3aa7e180fe60684e931f30236690bd27e24692f58d521ec4e","annotations":{"org.opencontainers.image.ref.name":"b3"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":1356,"digest":"sha256:4a88e72dd0e4245e6ecfbc6faae751eeeff82861f9ef39634bea07d77dbb1f40","annotations":{"org.opencontainers.image.ref.name":"v1"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":922,"digest":"sha256:adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8","annotations":{"org.opencontainers.image.ref.name":"v2"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":1141,"digest":"sha256:2e024d7fe67394cef7f0df9c303f288c7420d1fb27f7657398e897b7eb2ee1d8","annotations":{"org.opencontainers.image.ref.name":"v3"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":394,"digest":"sha256:fcb04a33e5e9ecd98d30b05a784de6fe65a26e84316a995c55a12dfbb02132e8","annotations":{"org.opencontainers.image.ref.name":"a-docker"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":532,"digest":"sha256:fd90fc9bbcdaace19655b8983e3d4efa946332f8c1f1aac40ac5bead09bdf9c9","annotations":{"org.opencontainers.image.ref.name":"a1"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":482,"digest":"sha256:a39a515fb72925723bb37fb46a7ff893444cfb22defa65646d5e15cf006fcbf3","annotations":{"org.opencontainers.image.ref.name":"sha256-adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":537,"digest":"sha256:b97ab0b611c4224b58c6b701a566b031b7a8fb7b644487668d31797013372cc4","annotations":{"org.opencontainers.image.ref.name":"a2"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":525,"digest":"sha256:7c759eba792dfa6a355a7e944e995add9e50de2c6401edf8d36d9b74ca1b9bde"},{"mediaType":"application/vnd.oci.image.index.v1+json","size":282,"digest":"sha256:468aaf700a290fd1f9d7f2236354c8d9635e4b5ff9c08b8ece722c3b20a653d0","annotations":{"org.opencontainers.image.ref.name":"sha256-ef05efc8cfd478ac3140fce1297bd6b72dc5f5f1df31bfce690aa903a2c20310"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":525,"digest":"sha256:6d8412d04fda16957bba600832e840ff144cb0f2c70bb14b8799d33fbb15e6a3"},{"mediaType":"application/vnd.oci.image.index.v1+json","size":282,"digest":"sha256:435bde2f7bea8501968fe3a9717b2d43eb7cb366a75ce2ca31fea0d7052c059c","annotations":{"org.opencontainers.image.ref.name":"sha256-5da622c67d8d1b2138493a79ec39250e784db584e2561bf0ab78c1e20b7962cf"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":525,"digest":"sha256:3c5862a104233f94c9b37ff0fbeba5adf26a9d1f6118fe6ee4f9b9e33a2e7a31"},{"mediaType":"application/vnd.oci.image.index.v1+json","size":282,"digest":"sha256:9858e0889fb2a74c261dc732c2aec004aa3020b15b92d68c80cd6225b90b5de2","annotations":{"org.opencontainers.image.ref.name":"sha256-56ed84e8d4f983411218751bcb5d544e14e28d210101a12c63a1be03108de01b"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":1141,"digest":"sha256:2e024d7fe67394cef7f0df9c303f288c7420d1fb27f7657398e897b7eb2ee1d8","annotations":{"org.opencontainers.image.ref.name":"sha256-4a88e72dd0e4245e6ecfbc6faae751eeeff82861f9ef39634bea07d77dbb1f40.2e024d7fe67394ce.meta"}}]}
\ No newline at end of file
+{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","size":964,"digest":"sha256:f91e00c1c151c75243a87073297efc68b8fe83a3dd82142ef6fb9f51dddb6a37","annotations":{"org.opencontainers.image.ref.name":"b1"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":964,"digest":"sha256:0a71ca431251e73ab58c6bbfa3d3343b9c0d3334aa6554052ee886711b15c93e","annotations":{"org.opencontainers.image.ref.name":"b2"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":964,"digest":"sha256:84078061572beea3aa7e180fe60684e931f30236690bd27e24692f58d521ec4e","annotations":{"org.opencontainers.image.ref.name":"b3"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":1356,"digest":"sha256:4a88e72dd0e4245e6ecfbc6faae751eeeff82861f9ef39634bea07d77dbb1f40","annotations":{"org.opencontainers.image.ref.name":"v1"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":922,"digest":"sha256:adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8","annotations":{"org.opencontainers.image.ref.name":"v2"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":1141,"digest":"sha256:2e024d7fe67394cef7f0df9c303f288c7420d1fb27f7657398e897b7eb2ee1d8","annotations":{"org.opencontainers.image.ref.name":"v3"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":394,"digest":"sha256:fcb04a33e5e9ecd98d30b05a784de6fe65a26e84316a995c55a12dfbb02132e8","annotations":{"org.opencontainers.image.ref.name":"a-docker"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":585,"digest":"sha256:798aa97e1710cb04671cad647d0b87159eed85cb6db4596806421ef190108c68","annotations":{"org.opencontainers.image.ref.name":"a1"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":482,"digest":"sha256:33616cd16da48b19f44b64704fde97c836bfb3aec4f857dcc6c359f736f62914","annotations":{"org.opencontainers.image.ref.name":"sha256-adab55c36c56f4054a64972a431e38e407d0060ce90888a2470d67598042f7c8"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":590,"digest":"sha256:557451ef26a12318261a561dd4df05dad756dc3dec7ee9d574f4f3f16ed1060f","annotations":{"org.opencontainers.image.ref.name":"a2"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":578,"digest":"sha256:2b7a146c97151d54a03f03768d15e20364391ab54cc72202c13bba2cb1a5d7ab"},{"mediaType":"application/vnd.oci.image.index.v1+json","size":282,"digest":"sha256:036ad24101294bf3306068c3d8a04754f79385550fbd83b2cc2bc59afec763fd","annotations":{"org.opencontainers.image.ref.name":"sha256-ef05efc8cfd478ac3140fce1297bd6b72dc5f5f1df31bfce690aa903a2c20310"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":578,"digest":"sha256:3c586eae9d15dcf39df07fc66fbee374fbe3fbf5f8c0b8b04bc9d7416973fa0a"},{"mediaType":"application/vnd.oci.image.index.v1+json","size":282,"digest":"sha256:a3192fd5d2b7571dd8dfb7bab6c348986a5daf6439c7464a1867be602a50a070","annotations":{"org.opencontainers.image.ref.name":"sha256-5da622c67d8d1b2138493a79ec39250e784db584e2561bf0ab78c1e20b7962cf"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":578,"digest":"sha256:96ab07dcc3f4845046a76d5b2115788180d9cfa1a2bd06a43c76804f387ab164"},{"mediaType":"application/vnd.oci.image.index.v1+json","size":282,"digest":"sha256:3ed6a2dc17e22660b7c5b19a007380f44a2ebc8c3e1269e934fd7e3688062042","annotations":{"org.opencontainers.image.ref.name":"sha256-56ed84e8d4f983411218751bcb5d544e14e28d210101a12c63a1be03108de01b"}},{"mediaType":"application/vnd.oci.image.index.v1+json","size":1141,"digest":"sha256:2e024d7fe67394cef7f0df9c303f288c7420d1fb27f7657398e897b7eb2ee1d8","annotations":{"org.opencontainers.image.ref.name":"sha256-4a88e72dd0e4245e6ecfbc6faae751eeeff82861f9ef39634bea07d77dbb1f40.2e024d7fe67394ce.meta"}}]}
\ No newline at end of file
diff --git a/types/referrer/referrer_test.go b/types/referrer/referrer_test.go
new file mode 100644
index 0000000..6a1ca26
--- /dev/null
+++ b/types/referrer/referrer_test.go
@@ -0,0 +1,334 @@
+package referrer
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/opencontainers/go-digest"
+	"github.com/regclient/regclient/types"
+	"github.com/regclient/regclient/types/manifest"
+	v1 "github.com/regclient/regclient/types/oci/v1"
+)
+
+const bOCIImg = `
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.oci.image.manifest.v1+json",
+  "config": {
+    "mediaType": "application/vnd.example.config.v1+json",
+    "size": 2,
+    "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
+  },
+  "layers": [
+    {
+      "mediaType": "application/vnd.example.data",
+      "size": 12,
+      "digest": "sha256:a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
+    }
+	],
+  "subject": {
+    "mediaType": "application/vnd.oci.image.manifest.v1+json",
+    "size": 1024,
+    "digest": "sha256:81b44ad77a83506e00079bfb7df04240df39d8da45891018b2e5e00d5d69aff3"
+  }
+}
+`
+
+const bOCIImgAT = `
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.oci.image.manifest.v1+json",
+	"artifactType": "application/vnd.example.data",
+  "config": {
+    "mediaType": "application/vnd.oci.scratch.v1+json",
+    "size": 2,
+    "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
+  },
+  "layers": [
+    {
+      "mediaType": "application/vnd.example.data",
+      "size": 12,
+      "digest": "sha256:a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
+    }
+	],
+	"annotations": {
+		"com.example.instance": "test",
+		"com.example.version": "1.0"
+	},
+  "subject": {
+    "mediaType": "application/vnd.oci.image.manifest.v1+json",
+    "size": 1024,
+    "digest": "sha256:81b44ad77a83506e00079bfb7df04240df39d8da45891018b2e5e00d5d69aff3"
+  }
+}
+`
+
+const bOCIIndex = `
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.oci.image.index.v1+json",
+  "manifests": [
+    {
+      "mediaType": "application/vnd.oci.image.manifest.v1+json",
+      "size": 1024,
+      "digest": "sha256:81b44ad77a83506e00079bfb7df04240df39d8da45891018b2e5e00d5d69aff3",
+      "platform": {
+        "architecture": "amd64",
+        "os": "linux"
+      }
+    },
+    {
+      "mediaType": "application/vnd.oci.image.manifest.v1+json",
+      "size": 1024,
+      "digest": "sha256:82c1fa5ffa9c65d121c4e1386f30ac51d360f546814ab193adaf9ecf8c6fb0f2",
+      "platform": {
+        "architecture": "arm64",
+        "os": "linux"
+      }
+    }
+	]
+}
+`
+
+const bDockerImg = `
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+  "config": {
+    "mediaType": "application/vnd.docker.container.image.v1+json",
+    "size": 1472,
+    "digest": "sha256:b2aa39c304c27b96c1fef0c06bee651ac9241d49c4fe34381cab8453f9a89c7d"
+  },
+  "layers": [
+    {
+      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+      "size": 3374446,
+      "digest": "sha256:63b65145d645c1250c391b2d16ebe53b3747c295ca8ba2fcb6b0cf064a4dc21c"
+    }
+  ]
+}
+`
+
+var mOCIImg, mOCIImgAT, mOCIIndex, mDockerImg manifest.Manifest
+var dOCIImg = types.Descriptor{
+	MediaType:    "application/vnd.oci.image.manifest.v1+json",
+	ArtifactType: "application/vnd.example.config.v1+json",
+	Size:         int64(len(bOCIImg)),
+	Digest:       digest.FromString(bOCIImg),
+}
+var dOCIImgAT = types.Descriptor{
+	MediaType:    "application/vnd.oci.image.manifest.v1+json",
+	ArtifactType: "application/vnd.example.config.v1+json",
+	Size:         int64(len(bOCIImgAT)),
+	Digest:       digest.FromString(bOCIImgAT),
+	Annotations: map[string]string{
+		"com.example.instance": "test",
+		"com.example.version":  "1.0",
+	},
+}
+
+func init() {
+	var err error
+	mOCIImg, err = manifest.New(manifest.WithRaw([]byte(bOCIImg)))
+	if err != nil {
+		panic(err)
+	}
+	mOCIImgAT, err = manifest.New(manifest.WithRaw([]byte(bOCIImgAT)))
+	if err != nil {
+		panic(err)
+	}
+	mOCIIndex, err = manifest.New(manifest.WithRaw([]byte(bOCIIndex)))
+	if err != nil {
+		panic(err)
+	}
+	mDockerImg, err = manifest.New(manifest.WithRaw([]byte(bDockerImg)))
+	if err != nil {
+		panic(err)
+	}
+}
+
+func TestEmpty(t *testing.T) {
+	// create an empty list and full list, test is empty
+	rlEmpty := &ReferrerList{
+		Descriptors: []types.Descriptor{},
+		Annotations: map[string]string{},
+		Tags:        []string{},
+	}
+	mEmpty, err := manifest.New(manifest.WithOrig(v1.Index{
+		Versioned: v1.IndexSchemaVersion,
+		MediaType: types.MediaTypeOCI1ManifestList,
+	}))
+	if err != nil {
+		t.Errorf("failed to generate index: %v", err)
+	}
+	rlEmpty.Manifest = mEmpty
+	if !rlEmpty.IsEmpty() {
+		t.Errorf("empty list returns false on IsEmpty")
+	}
+
+	rlPopulated := &ReferrerList{
+		Descriptors: []types.Descriptor{
+			dOCIImg,
+			dOCIImgAT,
+		},
+		Annotations: map[string]string{},
+		Tags:        []string{},
+	}
+	mPopulated, err := manifest.New(manifest.WithOrig(v1.Index{
+		Versioned: v1.IndexSchemaVersion,
+		MediaType: types.MediaTypeOCI1ManifestList,
+		Manifests: []types.Descriptor{
+			dOCIImg,
+			dOCIImgAT,
+		},
+	}))
+	if err != nil {
+		t.Errorf("failed to generate index: %v", err)
+	}
+	rlPopulated.Manifest = mPopulated
+	if rlPopulated.IsEmpty() {
+		t.Errorf("populated list returns true on IsEmpty")
+	}
+}
+
+func TestAdd(t *testing.T) {
+	tests := []struct {
+		name        string
+		m           manifest.Manifest
+		expectedErr error
+	}{
+		{
+			name: "OCI Image",
+			m:    mOCIImg,
+		},
+		{
+			name: "OCI Image artifact",
+			m:    mOCIImgAT,
+		},
+		{
+			name: "OCI Image again",
+			m:    mOCIImg,
+		},
+		{
+			name:        "OCI Index",
+			m:           mOCIIndex,
+			expectedErr: types.ErrUnsupportedMediaType,
+		},
+		{
+			name:        "Docker Image",
+			m:           mDockerImg,
+			expectedErr: types.ErrUnsupportedMediaType,
+		},
+	}
+	// add manifests (image without AT, image with AT, artifact, docker, no subject), verify list contents and error handling
+	rl := &ReferrerList{
+		Descriptors: []types.Descriptor{},
+		Annotations: map[string]string{},
+		Tags:        []string{},
+	}
+	m, err := manifest.New(manifest.WithOrig(v1.Index{
+		Versioned: v1.IndexSchemaVersion,
+		MediaType: types.MediaTypeOCI1ManifestList,
+	}))
+	if err != nil {
+		t.Errorf("failed to generate empty index: %v", err)
+	}
+	rl.Manifest = m
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := rl.Add(tt.m)
+			if tt.expectedErr != nil {
+				if err == nil {
+					t.Errorf("add succeeded, expected %v", tt.expectedErr)
+				} else if !errors.Is(err, tt.expectedErr) && err.Error() != tt.expectedErr.Error() {
+					t.Errorf("unexpected error, expected %v, received %v", tt.expectedErr, err)
+				}
+				return
+			} else if err != nil {
+				t.Errorf("add failed: %v", err)
+				return
+			}
+		})
+	}
+	// 3 success - 1 dup
+	if len(rl.Descriptors) != 2 {
+		t.Errorf("number of descriptors, expected 2, received %d", len(rl.Descriptors))
+	}
+	for _, d := range rl.Descriptors {
+		if d.ArtifactType == types.MediaTypeOCI1Scratch || d.ArtifactType == "" {
+			t.Errorf("unexpected artifact type: %s", d.ArtifactType)
+		}
+	}
+}
+
+func TestDelete(t *testing.T) {
+	rl := &ReferrerList{
+		Descriptors: []types.Descriptor{
+			dOCIImg,
+			dOCIImgAT,
+		},
+		Annotations: map[string]string{},
+		Tags:        []string{},
+	}
+	m, err := manifest.New(manifest.WithOrig(v1.Index{
+		Versioned: v1.IndexSchemaVersion,
+		MediaType: types.MediaTypeOCI1ManifestList,
+		Manifests: []types.Descriptor{
+			dOCIImg,
+			dOCIImgAT,
+		},
+	}))
+	if err != nil {
+		t.Errorf("failed to generate index: %v", err)
+	}
+	rl.Manifest = m
+
+	tests := []struct {
+		name        string
+		m           manifest.Manifest
+		expectedErr error
+	}{
+		{
+			name: "OCI Image",
+			m:    mOCIImg,
+		},
+		{
+			name: "OCI Image artifact",
+			m:    mOCIImgAT,
+		},
+		{
+			name:        "OCI Image again",
+			m:           mOCIImg,
+			expectedErr: types.ErrNotFound,
+		},
+		{
+			name:        "OCI Index",
+			m:           mOCIIndex,
+			expectedErr: types.ErrNotFound,
+		},
+		{
+			name:        "Docker Image",
+			m:           mDockerImg,
+			expectedErr: types.ErrNotFound,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := rl.Delete(tt.m)
+			if tt.expectedErr != nil {
+				if err == nil {
+					t.Errorf("delete succeeded, expected %v", tt.expectedErr)
+				} else if !errors.Is(err, tt.expectedErr) && err.Error() != tt.expectedErr.Error() {
+					t.Errorf("unexpected error, expected %v, received %v", tt.expectedErr, err)
+				}
+				return
+			} else if err != nil {
+				t.Errorf("delete failed: %v", err)
+				return
+			}
+		})
+	}
+	if len(rl.Descriptors) != 0 {
+		t.Errorf("number of descriptors, expected 0, received %d", len(rl.Descriptors))
+	}
+}