Repository: spegel-org/spegel
Pull request #784 (2025-03-17 09:26:35)

Refactor distribution and move to OCI package
This change is a general attempt to clean up some of the distribution parsing and make the testing of it centralized. It also moves the code to the OCI package. This will in turn make it easier for other potential packages to use the code.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/spegel-org/spegel/pull/784?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org) Report
Attention: Patch coverage is `69.13580%` with `25 lines` in your changes missing coverage. Please review.

| [Files with missing lines](https://app.codecov.io/gh/spegel-org/spegel/pull/784?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org) | Patch % | Lines |
|---|---|---|
| [pkg/registry/registry.go](https://app.codecov.io/gh/spegel-org/spegel/pull/784?src=pr&el=tree&filepath=pkg%2Fregistry%2Fregistry.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org#diff-cGtnL3JlZ2lzdHJ5L3JlZ2lzdHJ5Lmdv) | 23.33% | [22 Missing and 1 partial :warning: ](https://app.codecov.io/gh/spegel-org/spegel/pull/784?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org) |
| [pkg/oci/distribution.go](https://app.codecov.io/gh/spegel-org/spegel/pull/784?src=pr&el=tree&filepath=pkg%2Foci%2Fdistribution.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org#diff-cGtnL29jaS9kaXN0cmlidXRpb24uZ28=) | 96.07% | [2 Missing :warning: ](https://app.codecov.io/gh/spegel-org/spegel/pull/784?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org) |

| [Files with missing lines](https://app.codecov.io/gh/spegel-org/spegel/pull/784?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org) | Coverage Δ | |
|---|---|---|
| [pkg/oci/distribution.go](https://app.codecov.io/gh/spegel-org/spegel/pull/784?src=pr&el=tree&filepath=pkg%2Foci%2Fdistribution.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org#diff-cGtnL29jaS9kaXN0cmlidXRpb24uZ28=) | `96.07% <96.07%> (ø)` | |
| [pkg/registry/registry.go](https://app.codecov.io/gh/spegel-org/spegel/pull/784?src=pr&el=tree&filepath=pkg%2Fregistry%2Fregistry.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org#diff-cGtnL3JlZ2lzdHJ5L3JlZ2lzdHJ5Lmdv) | `53.54% <23.33%> (+0.23%)` | :arrow_up: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/spegel-org/spegel/pull/784/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=spegel-org)

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afd1dd8..686fd4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - [#373](https://github.com/spegel-org/spegel/pull/373) Apply mirror configuration on all registires by default.
 - [#762](https://github.com/spegel-org/spegel/pull/762) Set appropriate buckets for response size
 - [#778](https://github.com/spegel-org/spegel/pull/778) Replace interface{} with any alias.
+- [#784](https://github.com/spegel-org/spegel/pull/784) Refactor distribution and move to OCI package.
 
 ### Deprecated
 
diff --git a/pkg/oci/distribution.go b/pkg/oci/distribution.go
new file mode 100644
index 0000000..f28d81c
--- /dev/null
+++ b/pkg/oci/distribution.go
@@ -0,0 +1,101 @@
+package oci
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"regexp"
+
+	"github.com/opencontainers/go-digest"
+)
+
+var (
+	nameRegex           = regexp.MustCompile(`([a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*)`)
+	tagRegex            = regexp.MustCompile(`([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})`)
+	manifestRegexTag    = regexp.MustCompile(`/v2/` + nameRegex.String() + `/manifests/` + tagRegex.String() + `$`)
+	manifestRegexDigest = regexp.MustCompile(`/v2/` + nameRegex.String() + `/manifests/(.*)`)
+	blobsRegexDigest    = regexp.MustCompile(`/v2/` + nameRegex.String() + `/blobs/(.*)`)
+)
+
+// DistributionKind represents the kind of content.
+type DistributionKind string
+
+const (
+	DistributionKindManifest = "manifests"
+	DistributionKindBlob     = "blobs"
+)
+
+// DistributionPath contains the individual parameters from a OCI distribution spec request.
+type DistributionPath struct {
+	Kind     DistributionKind
+	Name     string
+	Digest   digest.Digest
+	Tag      string
+	Registry string
+}
+
+// Reference returns the digest if set or alternatively if not the full image reference with the tag.
+func (d DistributionPath) Reference() string {
+	if d.Digest != "" {
+		return d.Digest.String()
+	}
+	return fmt.Sprintf("%s/%s:%s", d.Registry, d.Name, d.Tag)
+}
+
+// IsLatestTag returns true if the tag has the value latest.
+func (d DistributionPath) IsLatestTag() bool {
+	return d.Tag == "latest"
+}
+
+// URL returns the reconstructed URL containing the path and query parameters.
+func (d DistributionPath) URL() *url.URL {
+	ref := d.Digest.String()
+	if ref == "" {
+		ref = d.Tag
+	}
+	return &url.URL{
+		Path:     fmt.Sprintf("/v2/%s/%s/%s", d.Name, d.Kind, ref),
+		RawQuery: fmt.Sprintf("ns=%s", d.Registry),
+	}
+}
+
+// ParseDistributionPath gets the parameters from a URL which conforms with the OCI distribution spec.
+// It returns a distribution path which contains all the individual parameters.
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md
+func ParseDistributionPath(u *url.URL) (DistributionPath, error) {
+	registry := u.Query().Get("ns")
+	comps := manifestRegexTag.FindStringSubmatch(u.Path)
+	if len(comps) == 6 {
+		if registry == "" {
+			return DistributionPath{}, errors.New("registry parameter needs to be set for tag references")
+		}
+		dist := DistributionPath{
+			Kind:     DistributionKindManifest,
+			Name:     comps[1],
+			Tag:      comps[5],
+			Registry: registry,
+		}
+		return dist, nil
+	}
+	comps = manifestRegexDigest.FindStringSubmatch(u.Path)
+	if len(comps) == 6 {
+		dist := DistributionPath{
+			Kind:     DistributionKindManifest,
+			Name:     comps[1],
+			Digest:   digest.Digest(comps[5]),
+			Registry: registry,
+		}
+		return dist, nil
+	}
+	comps = blobsRegexDigest.FindStringSubmatch(u.Path)
+	if len(comps) == 6 {
+		dist := DistributionPath{
+			Kind:     DistributionKindBlob,
+			Name:     comps[1],
+			Digest:   digest.Digest(comps[5]),
+			Registry: registry,
+		}
+		return dist, nil
+	}
+	return DistributionPath{}, errors.New("distribution path could not be parsed")
+}
diff --git a/pkg/registry/distribution.go b/pkg/registry/distribution.go
deleted file mode 100644
index 9a8d011..0000000
--- a/pkg/registry/distribution.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package registry
-
-import (
-	"errors"
-	"fmt"
-	"regexp"
-	"strings"
-
-	"github.com/opencontainers/go-digest"
-)
-
-type referenceKind string
-
-const (
-	referenceKindManifest = "Manifest"
-	referenceKindBlob     = "Blob"
-)
-
-type reference struct {
-	kind             referenceKind
-	name             string
-	dgst             digest.Digest
-	originalRegistry string
-}
-
-func (r reference) hasLatestTag() bool {
-	if r.name == "" {
-		return false
-	}
-	_, tag, _ := strings.Cut(r.name, ":")
-	return tag == "latest"
-}
-
-// Package is used to parse components from requests which comform with the OCI distribution spec.
-// https://github.com/opencontainers/distribution-spec/blob/main/spec.md
-// /v2/<name>/manifests/<reference>
-// /v2/<name>/blobs/<reference>
-
-var (
-	nameRegex           = regexp.MustCompile(`([a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*)`)
-	tagRegex            = regexp.MustCompile(`([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})`)
-	manifestRegexTag    = regexp.MustCompile(`/v2/` + nameRegex.String() + `/manifests/` + tagRegex.String() + `$`)
-	manifestRegexDigest = regexp.MustCompile(`/v2/` + nameRegex.String() + `/manifests/(.*)`)
-	blobsRegexDigest    = regexp.MustCompile(`/v2/` + nameRegex.String() + `/blobs/(.*)`)
-)
-
-func parsePathComponents(originalRegistry, path string) (reference, error) {
-	comps := manifestRegexTag.FindStringSubmatch(path)
-	if len(comps) == 6 {
-		if originalRegistry == "" {
-			return reference{}, errors.New("registry parameter needs to be set for tag references")
-		}
-		name := fmt.Sprintf("%s/%s:%s", originalRegistry, comps[1], comps[5])
-		ref := reference{
-			kind:             referenceKindManifest,
-			name:             name,
-			originalRegistry: originalRegistry,
-		}
-		return ref, nil
-	}
-	comps = manifestRegexDigest.FindStringSubmatch(path)
-	if len(comps) == 6 {
-		ref := reference{
-			kind:             referenceKindManifest,
-			dgst:             digest.Digest(comps[5]),
-			originalRegistry: originalRegistry,
-		}
-		return ref, nil
-	}
-	comps = blobsRegexDigest.FindStringSubmatch(path)
-	if len(comps) == 6 {
-		ref := reference{
-			kind:             referenceKindBlob,
-			dgst:             digest.Digest(comps[5]),
-			originalRegistry: originalRegistry,
-		}
-		return ref, nil
-	}
-	return reference{}, errors.New("distribution path could not be parsed")
-}
diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go
index 5996ab2..3d8e4bf 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -195,8 +195,7 @@ func (r *Registry) registryHandler(rw mux.ResponseWriter, req *http.Request) str
 	}
 
 	// Parse out path components from request.
-	originalRegistry := req.URL.Query().Get("ns")
-	ref, err := parsePathComponents(originalRegistry, req.URL.Path)
+	dist, err := oci.ParseDistributionPath(req.URL)
 	if err != nil {
 		rw.WriteError(http.StatusNotFound, fmt.Errorf("could not parse path according to OCI distribution spec: %w", err))
 		return "registry"
@@ -206,31 +205,26 @@ func (r *Registry) registryHandler(rw mux.ResponseWriter, req *http.Request) str
 	if req.Header.Get(MirroredHeaderKey) != "true" {
 		// Set mirrored header in request to stop infinite loops
 		req.Header.Set(MirroredHeaderKey, "true")
-		r.handleMirror(rw, req, ref)
+		r.handleMirror(rw, req, dist)
 		return "mirror"
 	}
 
 	// Serve registry endpoints.
-	switch ref.kind {
-	case referenceKindManifest:
-		r.handleManifest(rw, req, ref)
+	switch dist.Kind {
+	case oci.DistributionKindManifest:
+		r.handleManifest(rw, req, dist)
 		return "manifest"
-	case referenceKindBlob:
-		r.handleBlob(rw, req, ref)
+	case oci.DistributionKindBlob:
+		r.handleBlob(rw, req, dist)
 		return "blob"
 	default:
-		rw.WriteError(http.StatusNotFound, fmt.Errorf("unknown reference kind %s", ref.kind))
+		rw.WriteError(http.StatusNotFound, fmt.Errorf("unknown distribution path kind %s", dist.Kind))
 		return "registry"
 	}
 }
 
-func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, ref reference) {
-	key := ref.dgst.String()
-	if key == "" {
-		key = ref.name
-	}
-
-	log := r.log.WithValues("key", key, "path", req.URL.Path, "ip", getClientIP(req))
+func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, dist oci.DistributionPath) {
+	log := r.log.WithValues("ref", dist.Reference(), "path", req.URL.Path, "ip", getClientIP(req))
 
 	isExternal := r.isExternalRequest(req)
 	if isExternal {
@@ -246,20 +240,20 @@ func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, ref re
 		if rw.Status() != http.StatusOK {
 			cacheType = "miss"
 		}
-		metrics.MirrorRequestsTotal.WithLabelValues(ref.originalRegistry, cacheType, sourceType).Inc()
+		metrics.MirrorRequestsTotal.WithLabelValues(dist.Registry, cacheType, sourceType).Inc()
 	}()
 
-	if !r.resolveLatestTag && ref.hasLatestTag() {
-		r.log.V(4).Info("skipping mirror request for image with latest tag", "image", ref.name)
+	if !r.resolveLatestTag && dist.IsLatestTag() {
+		r.log.V(4).Info("skipping mirror request for image with latest tag", "image", dist.Reference())
 		rw.WriteHeader(http.StatusNotFound)
 		return
 	}
 
-	// Resolve mirror with the requested key
+	// Resolve mirror with the requested reference
 	resolveCtx, cancel := context.WithTimeout(req.Context(), r.resolveTimeout)
 	defer cancel()
 	resolveCtx = logr.NewContext(resolveCtx, log)
-	peerCh, err := r.router.Resolve(resolveCtx, key, isExternal, r.resolveRetries)
+	peerCh, err := r.router.Resolve(resolveCtx, dist.Reference(), isExternal, r.resolveRetries)
 	if err != nil {
 		rw.WriteError(http.StatusInternalServerError, fmt.Errorf("error occurred when attempting to resolve mirrors: %w", err))
 		return
@@ -270,12 +264,12 @@ func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, ref re
 		select {
 		case <-req.Context().Done():
 			// Request has been closed by server or client. No use continuing.
-			rw.WriteError(http.StatusNotFound, fmt.Errorf("mirroring for image component %s has been cancelled: %w", key, resolveCtx.Err()))
+			rw.WriteError(http.StatusNotFound, fmt.Errorf("mirroring for image component %s has been cancelled: %w", dist.Reference(), resolveCtx.Err()))
 			return
 		case ipAddr, ok := <-peerCh:
 			// Channel closed means no more mirrors will be received and max retries has been reached.
 			if !ok {
-				err = fmt.Errorf("mirror with image component %s could not be found", key)
+				err = fmt.Errorf("mirror with image component %s could not be found", dist.Reference())
 				if mirrorAttempts > 0 {
 					err = errors.Join(err, fmt.Errorf("requests to %d mirrors failed, all attempts have been exhausted or timeout has been reached", mirrorAttempts))
 				}
@@ -320,23 +314,23 @@ func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, ref re
 	}
 }
 
-func (r *Registry) handleManifest(rw mux.ResponseWriter, req *http.Request, ref reference) {
-	if ref.dgst == "" {
-		var err error
-		ref.dgst, err = r.ociClient.Resolve(req.Context(), ref.name)
+func (r *Registry) handleManifest(rw mux.ResponseWriter, req *http.Request, dist oci.DistributionPath) {
+	if dist.Digest == "" {
+		dgst, err := r.ociClient.Resolve(req.Context(), dist.Reference())
 		if err != nil {
-			rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get digest for image tag %s: %w", ref.name, err))
+			rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get digest for image %s: %w", dist.Reference(), err))
 			return
 		}
+		dist.Digest = dgst
 	}
-	b, mediaType, err := r.ociClient.GetManifest(req.Context(), ref.dgst)
+	b, mediaType, err := r.ociClient.GetManifest(req.Context(), dist.Digest)
 	if err != nil {
-		rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get manifest content for digest %s: %w", ref.dgst.String(), err))
+		rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get manifest content for digest %s: %w", dist.Digest.String(), err))
 		return
 	}
 	rw.Header().Set("Content-Type", mediaType)
 	rw.Header().Set("Content-Length", strconv.FormatInt(int64(len(b)), 10))
-	rw.Header().Set("Docker-Content-Digest", ref.dgst.String())
+	rw.Header().Set("Docker-Content-Digest", dist.Digest.String())
 	if req.Method == http.MethodHead {
 		return
 	}
@@ -347,23 +341,23 @@ func (r *Registry) handleManifest(rw mux.ResponseWriter, req *http.Request, ref
 	}
 }
 
-func (r *Registry) handleBlob(rw mux.ResponseWriter, req *http.Request, ref reference) {
-	size, err := r.ociClient.Size(req.Context(), ref.dgst)
+func (r *Registry) handleBlob(rw mux.ResponseWriter, req *http.Request, dist oci.DistributionPath) {
+	size, err := r.ociClient.Size(req.Context(), dist.Digest)
 	if err != nil {
-		rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not determine size of blob with digest %s: %w", ref.dgst.String(), err))
+		rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not determine size of blob with digest %s: %w", dist.Digest.String(), err))
 		return
 	}
 	rw.Header().Set("Accept-Ranges", "bytes")
 	rw.Header().Set("Content-Type", "application/octet-stream")
 	rw.Header().Set("Content-Length", strconv.FormatInt(size, 10))
-	rw.Header().Set("Docker-Content-Digest", ref.dgst.String())
+	rw.Header().Set("Docker-Content-Digest", dist.Digest.String())
 	if req.Method == http.MethodHead {
 		return
 	}
 
-	rc, err := r.ociClient.GetBlob(req.Context(), ref.dgst)
+	rc, err := r.ociClient.GetBlob(req.Context(), dist.Digest)
 	if err != nil {
-		rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not get reader for blob with digest %s: %w", ref.dgst.String(), err))
+		rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not get reader for blob with digest %s: %w", dist.Digest.String(), err))
 		return
 	}
 	defer rc.Close()


Test patch:
diff --git a/pkg/oci/distribution_test.go b/pkg/oci/distribution_test.go
new file mode 100644
index 0000000..cf5f40a
--- /dev/null
+++ b/pkg/oci/distribution_test.go
@@ -0,0 +1,113 @@
+package oci
+
+import (
+	"fmt"
+	"net/url"
+	"testing"
+
+	"github.com/opencontainers/go-digest"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseDistributionPath(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name                string
+		registry            string
+		path                string
+		expectedName        string
+		expectedDgst        digest.Digest
+		expectedTag         string
+		expectedRef         string
+		expectedKind        DistributionKind
+		execptedIsLatestTag bool
+	}{
+		{
+			name:                "manifest tag",
+			registry:            "example.com",
+			path:                "/v2/foo/bar/manifests/hello-world",
+			expectedName:        "foo/bar",
+			expectedDgst:        "",
+			expectedTag:         "hello-world",
+			expectedRef:         "example.com/foo/bar:hello-world",
+			expectedKind:        DistributionKindManifest,
+			execptedIsLatestTag: false,
+		},
+		{
+			name:                "manifest with latest tag",
+			registry:            "example.com",
+			path:                "/v2/test/manifests/latest",
+			expectedName:        "test",
+			expectedDgst:        "",
+			expectedTag:         "latest",
+			expectedRef:         "example.com/test:latest",
+			expectedKind:        DistributionKindManifest,
+			execptedIsLatestTag: true,
+		},
+		{
+			name:                "manifest digest",
+			registry:            "docker.io",
+			path:                "/v2/library/nginx/manifests/sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39",
+			expectedName:        "library/nginx",
+			expectedDgst:        digest.Digest("sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39"),
+			expectedTag:         "",
+			expectedRef:         "sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39",
+			expectedKind:        DistributionKindManifest,
+			execptedIsLatestTag: false,
+		},
+		{
+			name:                "blob digest",
+			registry:            "docker.io",
+			path:                "/v2/library/nginx/blobs/sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369",
+			expectedName:        "library/nginx",
+			expectedDgst:        digest.Digest("sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369"),
+			expectedTag:         "",
+			expectedRef:         "sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369",
+			expectedKind:        DistributionKindBlob,
+			execptedIsLatestTag: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			u := &url.URL{
+				Path:     tt.path,
+				RawQuery: fmt.Sprintf("ns=%s", tt.registry),
+			}
+			dist, err := ParseDistributionPath(u)
+			require.NoError(t, err)
+			require.Equal(t, tt.expectedName, dist.Name)
+			require.Equal(t, tt.expectedDgst, dist.Digest)
+			require.Equal(t, tt.expectedTag, dist.Tag)
+			require.Equal(t, tt.expectedRef, dist.Reference())
+			require.Equal(t, tt.expectedKind, dist.Kind)
+			require.Equal(t, tt.registry, dist.Registry)
+			require.Equal(t, tt.path, dist.URL().Path)
+			require.Equal(t, tt.registry, dist.URL().Query().Get("ns"))
+			require.Equal(t, tt.execptedIsLatestTag, dist.IsLatestTag())
+		})
+	}
+}
+
+func TestParseDistributionPathInvalidPath(t *testing.T) {
+	t.Parallel()
+
+	u := &url.URL{
+		Path:     "/v2/spegel-org/spegel/v0.0.1",
+		RawQuery: "ns=example.com",
+	}
+	_, err := ParseDistributionPath(u)
+	require.EqualError(t, err, "distribution path could not be parsed")
+}
+
+func TestParseDistributionPathMissingRegistry(t *testing.T) {
+	t.Parallel()
+
+	u := &url.URL{
+		Path: "/v2/spegel-org/spegel/manifests/v0.0.1",
+	}
+	_, err := ParseDistributionPath(u)
+	require.EqualError(t, err, "registry parameter needs to be set for tag references")
+}
diff --git a/pkg/registry/distribution_test.go b/pkg/registry/distribution_test.go
deleted file mode 100644
index 78b0856..0000000
--- a/pkg/registry/distribution_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package registry
-
-import (
-	"testing"
-
-	"github.com/opencontainers/go-digest"
-	"github.com/stretchr/testify/require"
-)
-
-func TestReference(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name     string
-		refName  string
-		expected bool
-	}{
-		{
-			name:     "with latest tag",
-			refName:  "ghcr.io/spegel-org/spegel:latest",
-			expected: true,
-		},
-		{
-			name:     "no latest tag",
-			refName:  "ghcr.io/spegel-org/spegel:v1.0.0",
-			expected: false,
-		},
-		{
-			name:     "empty name",
-			refName:  "",
-			expected: false,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			ref := reference{
-				name: tt.refName,
-			}
-			require.Equal(t, tt.expected, ref.hasLatestTag())
-		})
-	}
-}
-
-func TestParsePathComponents(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name            string
-		registry        string
-		path            string
-		expectedName    string
-		expectedDgst    digest.Digest
-		expectedRefKind referenceKind
-	}{
-		{
-			name:            "valid manifest tag",
-			registry:        "example.com",
-			path:            "/v2/foo/bar/manifests/hello-world",
-			expectedName:    "example.com/foo/bar:hello-world",
-			expectedDgst:    "",
-			expectedRefKind: referenceKindManifest,
-		},
-		{
-			name:            "valid manifest digest",
-			registry:        "docker.io",
-			path:            "/v2/library/nginx/manifests/sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39",
-			expectedName:    "",
-			expectedDgst:    digest.Digest("sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39"),
-			expectedRefKind: referenceKindManifest,
-		},
-		{
-			name:            "valid blob digest",
-			registry:        "docker.io",
-			path:            "/v2/library/nginx/blobs/sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369",
-			expectedName:    "",
-			expectedDgst:    digest.Digest("sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369"),
-			expectedRefKind: referenceKindBlob,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			ref, err := parsePathComponents(tt.registry, tt.path)
-			require.NoError(t, err)
-			require.Equal(t, tt.expectedName, ref.name)
-			require.Equal(t, tt.expectedDgst, ref.dgst)
-			require.Equal(t, tt.expectedRefKind, ref.kind)
-		})
-	}
-}
-
-func TestParsePathComponentsInvalidPath(t *testing.T) {
-	t.Parallel()
-
-	_, err := parsePathComponents("example.com", "/v2/spegel-org/spegel/v0.0.1")
-	require.EqualError(t, err, "distribution path could not be parsed")
-}
-
-func TestParsePathComponentsMissingRegistry(t *testing.T) {
-	t.Parallel()
-
-	_, err := parsePathComponents("", "/v2/spegel-org/spegel/manifests/v0.0.1")
-	require.EqualError(t, err, "registry parameter needs to be set for tag references")
-}