Repository: oras-project/oras-go

Issue:
add error type return by oras.Copy
**Background**
`oras.Copy` is consisted of several different operations. To add more context to the returned error, current implementation is to wrap a description string over the error, e.g. when resolving the source artifact fails, below string will be added to wrap the original error:

https://github.com/oras-project/oras-go/blob/f29607202d5a72ee5325171bf7fc6698ccb4e45f/copy.go#L138

**Problem**
This method imposes below challenges when processing the obtained error:
1) string context are hard to process: taking the above error as an example, it's hard to extract out the added context `failed to resolve $srcRef` out elegantly from caller side
2) lack information of where the error happens: an error might happens on source target or destination target, it's not reflected in the added context

**Solution**
Adding a structured error type for error returned by `oras.Copy` like below
```golang
type CopyError struct {
    // represents the failing operation, e.g. resolve
    Op string 
    // represents where it fails
    On string // enumerable string, source or dest
    // Inner error
    Err error
}
```

Discussion:

Solution patch:
diff --git a/copy.go b/copy.go
index adc30cb..2c61469 100644
--- a/copy.go
+++ b/copy.go
@@ -131,10 +131,10 @@ type CopyGraphOptions struct {
 // Returns the descriptor of the root node on successful copy.
 func Copy(ctx context.Context, src ReadOnlyTarget, srcRef string, dst Target, dstRef string, opts CopyOptions) (ocispec.Descriptor, error) {
 	if src == nil {
-		return ocispec.Descriptor{}, errors.New("nil source target")
+		return ocispec.Descriptor{}, newCopyError("Copy", CopyErrorOriginSource, errors.New("nil source target"))
 	}
 	if dst == nil {
-		return ocispec.Descriptor{}, errors.New("nil destination target")
+		return ocispec.Descriptor{}, newCopyError("Copy", CopyErrorOriginDestination, errors.New("nil destination target"))
 	}
 	if dstRef == "" {
 		dstRef = srcRef
@@ -147,14 +147,14 @@ func Copy(ctx context.Context, src ReadOnlyTarget, srcRef string, dst Target, ds
 	proxy := cas.NewProxyWithLimit(src, cas.NewMemory(), opts.MaxMetadataBytes)
 	root, err := resolveRoot(ctx, src, srcRef, proxy)
 	if err != nil {
-		return ocispec.Descriptor{}, fmt.Errorf("failed to resolve %s: %w", srcRef, err)
+		return ocispec.Descriptor{}, err
 	}
 
 	if opts.MapRoot != nil {
 		proxy.StopCaching = true
 		root, err = opts.MapRoot(ctx, proxy, root)
 		if err != nil {
-			return ocispec.Descriptor{}, err
+			return ocispec.Descriptor{}, newCopyError("MapRoot", CopyErrorOriginSource, err)
 		}
 		proxy.StopCaching = false
 	}
@@ -174,6 +174,12 @@ func Copy(ctx context.Context, src ReadOnlyTarget, srcRef string, dst Target, ds
 // from the source CAS to the destination CAS.
 // The root node (e.g. a manifest of the artifact) is identified by a descriptor.
 func CopyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, root ocispec.Descriptor, opts CopyGraphOptions) error {
+	if src == nil {
+		return newCopyError("CopyGraph", CopyErrorOriginSource, errors.New("nil source target"))
+	}
+	if dst == nil {
+		return newCopyError("CopyGraph", CopyErrorOriginDestination, errors.New("nil destination target"))
+	}
 	return copyGraph(ctx, src, dst, root, nil, nil, nil, opts)
 }
 
@@ -222,7 +228,7 @@ func copyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Sto
 		// skip if a rooted sub-DAG exists
 		exists, err := dst.Exists(ctx, desc)
 		if err != nil {
-			return err
+			return newCopyError("Exists", CopyErrorOriginDestination, err)
 		}
 		if exists {
 			if opts.OnCopySkipped != nil {
@@ -236,7 +242,7 @@ func copyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Sto
 		// find successors while non-leaf nodes will be fetched and cached
 		successors, err := opts.FindSuccessors(ctx, proxy, desc)
 		if err != nil {
-			return err
+			return newCopyError("FindSuccessors", CopyErrorOriginSource, err)
 		}
 		successors = removeForeignLayers(successors)
 
@@ -264,7 +270,7 @@ func copyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Sto
 
 		exists, err = proxy.Cache.Exists(ctx, desc)
 		if err != nil {
-			return err
+			return fmt.Errorf("failed to check cache existence: %s: %w", desc.Digest, err)
 		}
 		if exists {
 			return copyNode(ctx, proxy.Cache, dst, desc, opts)
@@ -324,7 +330,7 @@ func mountOrCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst conte
 
 		// Mount or copy
 		if err := mounter.Mount(ctx, desc, sourceRepository, getContent); err != nil && !errors.Is(err, skipSource) {
-			return err
+			return newCopyError("Mount", CopyErrorOriginDestination, err)
 		}
 
 		if !mountFailed {
@@ -352,12 +358,12 @@ func mountOrCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst conte
 func doCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor) error {
 	rc, err := src.Fetch(ctx, desc)
 	if err != nil {
-		return err
+		return newCopyError("Fetch", CopyErrorOriginSource, err)
 	}
 	defer rc.Close()
 	err = dst.Push(ctx, desc, rc)
 	if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
-		return err
+		return newCopyError("Push", CopyErrorOriginDestination, err)
 	}
 	return nil
 }
@@ -389,13 +395,13 @@ func copyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Stor
 func copyCachedNodeWithReference(ctx context.Context, src *cas.Proxy, dst registry.ReferencePusher, desc ocispec.Descriptor, dstRef string) error {
 	rc, err := src.FetchCached(ctx, desc)
 	if err != nil {
-		return err
+		return newCopyError("Fetch", CopyErrorOriginSource, err)
 	}
 	defer rc.Close()
 
 	err = dst.PushReference(ctx, desc, rc, dstRef)
 	if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
-		return err
+		return newCopyError("PushReference", CopyErrorOriginDestination, err)
 	}
 	return nil
 }
@@ -404,7 +410,11 @@ func copyCachedNodeWithReference(ctx context.Context, src *cas.Proxy, dst regist
 func resolveRoot(ctx context.Context, src ReadOnlyTarget, srcRef string, proxy *cas.Proxy) (ocispec.Descriptor, error) {
 	refFetcher, ok := src.(registry.ReferenceFetcher)
 	if !ok {
-		return src.Resolve(ctx, srcRef)
+		desc, err := src.Resolve(ctx, srcRef)
+		if err != nil {
+			return ocispec.Descriptor{}, newCopyError("Resolve", CopyErrorOriginSource, err)
+		}
+		return desc, nil
 	}
 
 	// optimize performance for ReferenceFetcher targets
@@ -414,7 +424,7 @@ func resolveRoot(ctx context.Context, src ReadOnlyTarget, srcRef string, proxy *
 	}
 	root, rc, err := refProxy.FetchReference(ctx, srcRef)
 	if err != nil {
-		return ocispec.Descriptor{}, err
+		return ocispec.Descriptor{}, newCopyError("FetchReference", CopyErrorOriginSource, err)
 	}
 	defer rc.Close()
 	// cache root if it is a non-leaf node
@@ -425,7 +435,7 @@ func resolveRoot(ctx context.Context, src ReadOnlyTarget, srcRef string, proxy *
 		return nil, errors.New("fetching only root node expected")
 	})
 	if _, err = content.Successors(ctx, fetcher, root); err != nil {
-		return ocispec.Descriptor{}, err
+		return ocispec.Descriptor{}, newCopyError("Successors", CopyErrorOriginSource, err)
 	}
 
 	// TODO: optimize special case where root is a leaf node (i.e. a blob)
@@ -434,7 +444,7 @@ func resolveRoot(ctx context.Context, src ReadOnlyTarget, srcRef string, proxy *
 }
 
 // prepareCopy prepares the hooks for copy.
-func prepareCopy(ctx context.Context, dst Target, dstRef string, proxy *cas.Proxy, root ocispec.Descriptor, opts *CopyOptions) error {
+func prepareCopy(_ context.Context, dst Target, dstRef string, proxy *cas.Proxy, root ocispec.Descriptor, opts *CopyOptions) error {
 	if refPusher, ok := dst.(registry.ReferencePusher); ok {
 		// optimize performance for ReferencePusher targets
 		preCopy := opts.PreCopy
@@ -467,7 +477,7 @@ func prepareCopy(ctx context.Context, dst Target, dstRef string, proxy *cas.Prox
 			if content.Equal(desc, root) {
 				// for root node, tag it after copying it
 				if err := dst.Tag(ctx, root, dstRef); err != nil {
-					return err
+					return newCopyError("Tag", CopyErrorOriginDestination, err)
 				}
 			}
 			if postCopy != nil {
@@ -499,7 +509,10 @@ func prepareCopy(ctx context.Context, dst Target, dstRef string, proxy *cas.Prox
 				return err
 			}
 		}
-		return dst.Tag(ctx, root, dstRef)
+		if err := dst.Tag(ctx, root, dstRef); err != nil {
+			return newCopyError("Tag", CopyErrorOriginDestination, err)
+		}
+		return nil
 	}
 
 	return nil
diff --git a/copyerror.go b/copyerror.go
new file mode 100644
index 0000000..03ed8e2
--- /dev/null
+++ b/copyerror.go
@@ -0,0 +1,78 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package oras
+
+import "fmt"
+
+// CopyErrorOrigin defines the source of a copy error.
+type CopyErrorOrigin int
+
+const (
+	// CopyErrorOriginSource indicates the error occurred at the source side.
+	CopyErrorOriginSource CopyErrorOrigin = 1
+
+	// CopyErrorOriginDestination indicates the error occurred at the destination side.
+	CopyErrorOriginDestination CopyErrorOrigin = 2
+)
+
+// String returns the string representation of the CopyErrorOrigin.
+func (o CopyErrorOrigin) String() string {
+	switch o {
+	case CopyErrorOriginSource:
+		return "source"
+	case CopyErrorOriginDestination:
+		return "destination"
+	default:
+		return "unknown"
+	}
+}
+
+// CopyError represents an error encountered during a copy operation.
+type CopyError struct {
+	// Op is the operation that caused the error.
+	Op string
+	// Origin indicates the source of the error.
+	Origin CopyErrorOrigin
+	// Err is the underlying error.
+	Err error
+}
+
+// newCopyError creates a new CopyError.
+func newCopyError(op string, origin CopyErrorOrigin, err error) error {
+	if err == nil {
+		return nil
+	}
+	return &CopyError{
+		Op:     op,
+		Origin: origin,
+		Err:    err,
+	}
+}
+
+// Error implements the error interface for CopyError.
+func (e *CopyError) Error() string {
+	switch e.Origin {
+	case CopyErrorOriginSource, CopyErrorOriginDestination:
+		return fmt.Sprintf("failed to perform %q on %s: %v", e.Op, e.Origin, e.Err)
+	default:
+		return fmt.Sprintf("failed to perform %q: %v", e.Op, e.Err)
+	}
+}
+
+// Unwrap implements the errors.Unwrap interface for CopyError.
+func (e *CopyError) Unwrap() error {
+	return e.Err
+}
diff --git a/extendedcopy.go b/extendedcopy.go
index e592394..40f5783 100644
--- a/extendedcopy.go
+++ b/extendedcopy.go
@@ -76,10 +76,10 @@ type ExtendedCopyGraphOptions struct {
 // Returns the descriptor of the tagged node on successful copy.
 func ExtendedCopy(ctx context.Context, src ReadOnlyGraphTarget, srcRef string, dst Target, dstRef string, opts ExtendedCopyOptions) (ocispec.Descriptor, error) {
 	if src == nil {
-		return ocispec.Descriptor{}, errors.New("nil source graph target")
+		return ocispec.Descriptor{}, newCopyError("ExtendedCopy", CopyErrorOriginSource, errors.New("nil source target"))
 	}
 	if dst == nil {
-		return ocispec.Descriptor{}, errors.New("nil destination target")
+		return ocispec.Descriptor{}, newCopyError("ExtendedCopy", CopyErrorOriginDestination, errors.New("nil destination target"))
 	}
 	if dstRef == "" {
 		dstRef = srcRef
@@ -87,7 +87,7 @@ func ExtendedCopy(ctx context.Context, src ReadOnlyGraphTarget, srcRef string, d
 
 	node, err := src.Resolve(ctx, srcRef)
 	if err != nil {
-		return ocispec.Descriptor{}, err
+		return ocispec.Descriptor{}, newCopyError("Resolve", CopyErrorOriginSource, err)
 	}
 
 	if err := ExtendedCopyGraph(ctx, src, dst, node, opts.ExtendedCopyGraphOptions); err != nil {
@@ -95,7 +95,7 @@ func ExtendedCopy(ctx context.Context, src ReadOnlyGraphTarget, srcRef string, d
 	}
 
 	if err := dst.Tag(ctx, node, dstRef); err != nil {
-		return ocispec.Descriptor{}, err
+		return ocispec.Descriptor{}, newCopyError("Tag", CopyErrorOriginDestination, err)
 	}
 
 	return node, nil
@@ -107,6 +107,13 @@ func ExtendedCopy(ctx context.Context, src ReadOnlyGraphTarget, srcRef string, d
 // predecessor manifests referencing it.
 // The node (e.g. a manifest of the artifact) is identified by a descriptor.
 func ExtendedCopyGraph(ctx context.Context, src content.ReadOnlyGraphStorage, dst content.Storage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) error {
+	if src == nil {
+		return newCopyError("ExtendedCopyGraph", CopyErrorOriginSource, errors.New("nil source target"))
+	}
+	if dst == nil {
+		return newCopyError("ExtendedCopyGraph", CopyErrorOriginDestination, errors.New("nil destination target"))
+	}
+
 	roots, err := findRoots(ctx, src, node, opts)
 	if err != nil {
 		return err
@@ -182,7 +189,7 @@ func findRoots(ctx context.Context, storage content.ReadOnlyGraphStorage, node o
 
 		predecessors, err := opts.FindPredecessors(ctx, storage, currentNode)
 		if err != nil {
-			return nil, err
+			return nil, newCopyError("FindPredecessors", CopyErrorOriginSource, err)
 		}
 
 		// The current node has no predecessor node,
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
index 3aea3a1..60d000d 100644
--- a/internal/platform/platform.go
+++ b/internal/platform/platform.go
@@ -127,7 +127,7 @@ func SelectManifest(ctx context.Context, src content.ReadOnlyStorage, root ocisp
 // fields in config blob.
 func getPlatformFromConfig(ctx context.Context, src content.ReadOnlyStorage, desc ocispec.Descriptor, targetConfigMediaType string) (*ocispec.Platform, error) {
 	if desc.MediaType != targetConfigMediaType {
-		return nil, fmt.Errorf("fail to recognize platform from unknown config %s: expect %s", desc.MediaType, targetConfigMediaType)
+		return nil, fmt.Errorf("fail to recognize platform from unknown config %s: expect %s: %w", desc.MediaType, targetConfigMediaType, errdef.ErrUnsupported)
 	}
 
 	rc, err := src.Fetch(ctx, desc)
diff --git a/internal/resolver/memory.go b/internal/resolver/memory.go
index 092a29e..df710e6 100644
--- a/internal/resolver/memory.go
+++ b/internal/resolver/memory.go
@@ -17,6 +17,7 @@ package resolver
 
 import (
 	"context"
+	"fmt"
 	"maps"
 	"sync"
 
@@ -48,7 +49,7 @@ func (m *Memory) Resolve(_ context.Context, reference string) (ocispec.Descripto
 
 	desc, ok := m.index[reference]
 	if !ok {
-		return ocispec.Descriptor{}, errdef.ErrNotFound
+		return ocispec.Descriptor{}, fmt.Errorf("%s: %w", reference, errdef.ErrNotFound)
 	}
 	return desc, nil
 }



Test patch:
diff --git a/copy_test.go b/copy_test.go
index a9b3fab..2adc4c0 100644
--- a/copy_test.go
+++ b/copy_test.go
@@ -866,9 +866,8 @@ func TestCopy_WithTargetPlatformOptions(t *testing.T) {
 	opts.WithTargetPlatform(&targetPlatform)
 
 	_, err = oras.Copy(ctx, src, ref, dst, "", opts)
-	expected := fmt.Sprintf("%s: %v: no matching manifest was found in the manifest list", root.Digest, errdef.ErrNotFound)
-	if err.Error() != expected {
-		t.Fatalf("Copy() error = %v, wantErr %v", err, expected)
+	if wantErr := errdef.ErrNotFound; !errors.Is(err, wantErr) {
+		t.Errorf("Copy() error = %v, wantErr %v", err, wantErr)
 	}
 
 	// test copy with platform filter for the manifest
@@ -927,9 +926,8 @@ func TestCopy_WithTargetPlatformOptions(t *testing.T) {
 	opts.WithTargetPlatform(&targetPlatform)
 
 	_, err = oras.Copy(ctx, src, ref, dst, "", opts)
-	expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound)
-	if err.Error() != expected {
-		t.Fatalf("Copy() error = %v, wantErr %v", err, expected)
+	if wantErr := errdef.ErrNotFound; !errors.Is(err, wantErr) {
+		t.Errorf("Copy() error = %v, wantErr %v", err, wantErr)
 	}
 
 	// test copy with platform filter, but the node's media type is not
@@ -992,9 +990,8 @@ func TestCopy_WithTargetPlatformOptions(t *testing.T) {
 	}
 
 	_, err = oras.Copy(ctx, src, ref, dst, "", opts)
-	expected = fmt.Sprintf("fail to recognize platform from unknown config %s: expect %s", docker.MediaTypeConfig, ocispec.MediaTypeImageConfig)
-	if err.Error() != expected {
-		t.Fatalf("Copy() error = %v, wantErr %v", err, expected)
+	if wantErr := errdef.ErrUnsupported; !errors.Is(err, wantErr) {
+		t.Errorf("Copy() error = %v, wantErr %v", err, wantErr)
 	}
 
 	// generate test content with null config blob
@@ -1030,9 +1027,8 @@ func TestCopy_WithTargetPlatformOptions(t *testing.T) {
 	}
 
 	_, err = oras.Copy(ctx, src, ref, dst, "", opts)
-	expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound)
-	if err.Error() != expected {
-		t.Fatalf("Copy() error = %v, wantErr %v", err, expected)
+	if wantErr := errdef.ErrNotFound; !errors.Is(err, wantErr) {
+		t.Errorf("Copy() error = %v, wantErr %v", err, wantErr)
 	}
 
 	// generate test content with empty config blob
@@ -1068,9 +1064,8 @@ func TestCopy_WithTargetPlatformOptions(t *testing.T) {
 	}
 
 	_, err = oras.Copy(ctx, src, ref, dst, "", opts)
-	expected = fmt.Sprintf("%s: %v: platform in manifest does not match target platform", root.Digest, errdef.ErrNotFound)
-	if err.Error() != expected {
-		t.Fatalf("Copy() error = %v, wantErr %v", err, expected)
+	if wantErr := errdef.ErrNotFound; !errors.Is(err, wantErr) {
+		t.Errorf("Copy() error = %v, wantErr %v", err, wantErr)
 	}
 
 	// test copy with no platform filter and nil opts.MapRoot
@@ -2323,29 +2318,320 @@ func TestCopy_ReferencePusher(t *testing.T) {
 	}
 }
 
-func TestCopy_Error(t *testing.T) {
+func TestCopy_CopyError(t *testing.T) {
 	t.Run("src target is nil", func(t *testing.T) {
 		ctx := context.Background()
 		dst := memory.New()
-		if _, err := oras.Copy(ctx, nil, "", dst, "", oras.DefaultCopyOptions); err == nil {
-			t.Errorf("Copy() error = %v, wantErr %v", err, true)
+		_, err := oras.Copy(ctx, nil, "", dst, "", oras.DefaultCopyOptions)
+		if err == nil {
+			t.Fatalf("Copy() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("Copy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Fatalf("CopyError origin = %v, want %v", copyErr.Origin, want)
 		}
 	})
 
 	t.Run("dst target is nil", func(t *testing.T) {
 		ctx := context.Background()
 		src := memory.New()
-		if _, err := oras.Copy(ctx, src, "", nil, "", oras.DefaultCopyOptions); err == nil {
+		_, err := oras.Copy(ctx, src, "", nil, "", oras.DefaultCopyOptions)
+		if err == nil {
 			t.Errorf("Copy() error = %v, wantErr %v", err, true)
 		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("Copy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Fatalf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
 	})
 
 	t.Run("failed to resolve reference", func(t *testing.T) {
 		ctx := context.Background()
 		src := memory.New()
 		dst := memory.New()
-		if _, err := oras.Copy(ctx, src, "whatever", dst, "", oras.DefaultCopyOptions); err == nil {
+		_, err := oras.Copy(ctx, src, "whatever", dst, "", oras.DefaultCopyOptions)
+		if err == nil {
+			t.Errorf("Copy() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("Copy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Fatalf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+	})
+
+	t.Run("tag error", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		dst := &badTagger{
+			Target: memory.New(),
+		}
+		srcRef := "test"
+
+		// prepare test content
+		manifestDesc, err := oras.PackManifest(ctx, src, oras.PackManifestVersion1_1, "application/test", oras.PackManifestOptions{})
+		if err != nil {
+			t.Fatalf("failed to pack test content: %v", err)
+		}
+		if err := src.Tag(ctx, manifestDesc, srcRef); err != nil {
+			t.Fatalf("failed to tag test content on src: %v", err)
+		}
+
+		// test copy
+		_, err = oras.Copy(ctx, src, srcRef, dst, "", oras.DefaultCopyOptions)
+		if err == nil {
 			t.Errorf("Copy() error = %v, wantErr %v", err, true)
 		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("Copy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errTag; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyError error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
 	})
+
+}
+
+func TestCopyGraph_CopyError(t *testing.T) {
+	t.Run("src target is nil", func(t *testing.T) {
+		ctx := context.Background()
+		dst := memory.New()
+		err := oras.CopyGraph(ctx, nil, dst, ocispec.Descriptor{}, oras.DefaultCopyGraphOptions)
+		if err == nil {
+			t.Fatalf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Fatalf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+	})
+
+	t.Run("dst target is nil", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		err := oras.CopyGraph(ctx, src, nil, ocispec.Descriptor{}, oras.DefaultCopyGraphOptions)
+		if err == nil {
+			t.Fatalf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Fatalf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+	})
+
+	t.Run("exists error", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		dst := &badExister{
+			memory.New(),
+		}
+		err := oras.CopyGraph(ctx, src, dst, ocispec.Descriptor{}, oras.DefaultCopyGraphOptions)
+		if err == nil {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errExists; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+
+	t.Run("fetch error", func(t *testing.T) {
+		ctx := context.Background()
+		src := &badFetcher{
+			memory.New(),
+		}
+		dst := memory.New()
+		err := oras.CopyGraph(ctx, src, dst, ocispec.Descriptor{}, oras.DefaultCopyGraphOptions)
+		if err == nil {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errFetch; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+
+	t.Run("push error", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		dst := &badPusher{
+			memory.New(),
+		}
+
+		// prepare test content
+		manifestDesc, err := oras.PackManifest(ctx, src, oras.PackManifestVersion1_1, "application/test", oras.PackManifestOptions{})
+		if err != nil {
+			t.Fatalf("failed to pack test content: %v", err)
+		}
+
+		err = oras.CopyGraph(ctx, src, dst, manifestDesc, oras.DefaultCopyGraphOptions)
+		if err == nil {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errPush; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+
+	t.Run("mount fetch error", func(t *testing.T) {
+		ctx := context.Background()
+		src := &badFetcher{
+			memory.New(),
+		}
+		dst := &testMounter{
+			memory.New(),
+		}
+
+		opts := oras.CopyGraphOptions{
+			MountFrom: func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) {
+				return []string{"source"}, nil
+			},
+		}
+		err := oras.CopyGraph(ctx, src, dst, ocispec.Descriptor{}, opts)
+		if err == nil {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errFetch; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+
+	t.Run("mount error", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		dst := &badMounter{
+			memory.New(),
+		}
+
+		// prepare test content
+		manifestDesc, err := oras.PackManifest(ctx, src, oras.PackManifestVersion1_1, "application/test", oras.PackManifestOptions{})
+		if err != nil {
+			t.Fatalf("failed to pack test content: %v", err)
+		}
+
+		// test copy graph
+		opts := oras.CopyGraphOptions{
+			MountFrom: func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) {
+				return []string{"source"}, nil
+			},
+		}
+		err = oras.CopyGraph(ctx, src, dst, manifestDesc, opts)
+		if err == nil {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("CopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errMount; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyGraph() error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+}
+
+var (
+	errExists = errors.New("exists error")
+	errPush   = errors.New("push error")
+	errFetch  = errors.New("fetch error")
+	errTag    = errors.New("tag error")
+	errMount  = errors.New("mount error")
+)
+
+type badExister struct {
+	content.Storage
+}
+
+func (be *badExister) Exists(_ context.Context, _ ocispec.Descriptor) (bool, error) {
+	return false, errExists
+}
+
+type badFetcher struct {
+	content.Storage
+}
+
+func (bf *badFetcher) Fetch(_ context.Context, _ ocispec.Descriptor) (io.ReadCloser, error) {
+	return nil, errFetch
+}
+
+type badPusher struct {
+	content.Storage
+}
+
+func (bp *badPusher) Push(_ context.Context, _ ocispec.Descriptor, _ io.Reader) error {
+	return errPush
+}
+
+type badTagger struct {
+	oras.Target
+}
+
+func (bt *badTagger) Tag(_ context.Context, _ ocispec.Descriptor, _ string) error {
+	return errTag
+}
+
+type testMounter struct {
+	oras.Target
+}
+
+func (tm *testMounter) Mount(_ context.Context, _ ocispec.Descriptor, _ string, getContent func() (io.ReadCloser, error)) error {
+	_, err := getContent()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+type badMounter struct {
+	oras.Target
+}
+
+func (bm *badMounter) Mount(_ context.Context, _ ocispec.Descriptor, _ string, _ func() (io.ReadCloser, error)) error {
+	return errMount
 }
diff --git a/copyerror_test.go b/copyerror_test.go
new file mode 100644
index 0000000..87710dd
--- /dev/null
+++ b/copyerror_test.go
@@ -0,0 +1,241 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package oras
+
+import (
+	"errors"
+	"testing"
+)
+
+var errTest error = errors.New("test error")
+
+func TestNewCopyError(t *testing.T) {
+	tests := []struct {
+		name   string
+		op     string
+		origin CopyErrorOrigin
+		err    error
+		want   *CopyError
+	}{
+		{
+			name:   "source error",
+			op:     "pull",
+			origin: CopyErrorOriginSource,
+			err:    errTest,
+			want: &CopyError{
+				Op:     "pull",
+				Origin: CopyErrorOriginSource,
+				Err:    errTest,
+			},
+		},
+		{
+			name:   "destination error",
+			op:     "push",
+			origin: CopyErrorOriginDestination,
+			err:    errTest,
+			want: &CopyError{
+				Op:     "push",
+				Origin: CopyErrorOriginDestination,
+				Err:    errTest,
+			},
+		},
+		{
+			name:   "undefined origin",
+			op:     "test",
+			origin: -1,
+			err:    errTest,
+			want: &CopyError{
+				Op:     "test",
+				Origin: -1,
+				Err:    errTest,
+			},
+		},
+		{
+			name:   "nil error",
+			op:     "test",
+			origin: CopyErrorOriginSource,
+			err:    nil,
+			want:   nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := newCopyError(tt.op, tt.origin, tt.err)
+			if tt.want == nil {
+				return
+			}
+			copyErr, ok := err.(*CopyError)
+			if !ok {
+				t.Fatalf("expected err to be *CopyError, got %T", err)
+			}
+
+			if copyErr.Op != tt.want.Op {
+				t.Errorf("expected Op %q, got %q", tt.want.Op, copyErr.Op)
+			}
+
+			if copyErr.Origin != tt.want.Origin {
+				t.Errorf("expected Origin %q, got %q", tt.want.Origin, copyErr.Origin)
+			}
+
+			if !errors.Is(copyErr.Err, errTest) {
+				t.Errorf("expected Err %q, got %q", tt.want.Err, copyErr.Err)
+			}
+		})
+	}
+}
+
+func TestCopyError_Error(t *testing.T) {
+	tests := []struct {
+		name    string
+		copyErr *CopyError
+		want    string
+	}{
+		{
+			name: "source error",
+			copyErr: &CopyError{
+				Op:     "pull",
+				Origin: CopyErrorOriginSource,
+				Err:    errTest,
+			},
+			want: `failed to perform "pull" on source: test error`,
+		},
+		{
+			name: "destination error",
+			copyErr: &CopyError{
+				Op:     "push",
+				Origin: CopyErrorOriginDestination,
+				Err:    errTest,
+			},
+			want: `failed to perform "push" on destination: test error`,
+		},
+		{
+			name: "undefined origin",
+			copyErr: &CopyError{
+				Op:     "test",
+				Origin: -1,
+				Err:    errTest,
+			},
+			want: `failed to perform "test": test error`,
+		},
+		{
+			name: "nil error",
+			copyErr: &CopyError{
+				Op:     "test",
+				Origin: CopyErrorOriginSource,
+				Err:    nil,
+			},
+			want: `failed to perform "test" on source: <nil>`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if errStr := tt.copyErr.Error(); errStr != tt.want {
+				t.Errorf("want %q, got %q", tt.want, errStr)
+			}
+		})
+	}
+}
+
+func TestCopyError_Unwrap(t *testing.T) {
+	tests := []struct {
+		name    string
+		copyErr *CopyError
+		want    error
+	}{
+		{
+			name: "unwrap source error",
+			copyErr: &CopyError{
+				Op:     "pull",
+				Origin: CopyErrorOriginSource,
+				Err:    errTest,
+			},
+			want: errTest,
+		},
+		{
+			name: "unwrap destination error",
+			copyErr: &CopyError{
+				Op:     "push",
+				Origin: CopyErrorOriginDestination,
+				Err:    errTest,
+			},
+			want: errTest,
+		},
+		{
+			name: "undefined origin",
+			copyErr: &CopyError{
+				Op:     "test",
+				Origin: -1,
+				Err:    errTest,
+			},
+			want: errTest,
+		},
+		{
+			name: "nil error",
+			copyErr: &CopyError{
+				Op:     "test",
+				Origin: CopyErrorOriginSource,
+				Err:    nil,
+			},
+			want: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := tt.copyErr.Unwrap(); err != tt.want {
+				t.Errorf("want %v, got %v", tt.want, err)
+			}
+		})
+	}
+}
+
+func TestCopyError_Nested(t *testing.T) {
+	msg := "custom error"
+	err := &CopyError{
+		Op:     "test",
+		Origin: CopyErrorOriginSource,
+		Err: &customErr{
+			Msg: msg,
+		},
+	}
+
+	var cpErr *CopyError
+	if !errors.As(err, &cpErr) {
+		t.Fatalf("expected %T, got %T", cpErr, err)
+	}
+
+	var ce *customErr
+	if !errors.As(err, &ce) {
+		t.Fatalf("expected %T, got %T", ce, err)
+	}
+	if ce.Msg != msg {
+		t.Errorf("expected %q, got %q", msg, ce.Msg)
+	}
+}
+
+type customErr struct {
+	Msg string
+}
+
+func (e *customErr) Error() string {
+	return e.Msg
+}
+
+func (e *customErr) Unwrap() error {
+	return nil
+}
diff --git a/example_copyerror_test.go b/example_copyerror_test.go
new file mode 100644
index 0000000..f61c2d7
--- /dev/null
+++ b/example_copyerror_test.go
@@ -0,0 +1,59 @@
+/*
+Copyright The ORAS Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package oras_test
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"oras.land/oras-go/v2"
+	"oras.land/oras-go/v2/content/memory"
+)
+
+// ExampleCopyError demonstrates how to check CopyError returned from
+// copy operations.
+func ExampleCopyError() {
+	src := memory.New()
+	dst := memory.New()
+	ctx := context.Background()
+
+	// Try to copy a non-existent reference, which is expected to fail
+	nonExistentRef := "non-existent-reference"
+	_, err := oras.Copy(ctx, src, nonExistentRef, dst, "", oras.DefaultCopyOptions)
+	if err != nil {
+		// Check if the error is a CopyError and print its details
+		var copyErr *oras.CopyError
+		if errors.As(err, &copyErr) {
+			fmt.Println("copyErr.Origin:", copyErr.Origin)
+			fmt.Println("copyErr.Op:", copyErr.Op)
+			fmt.Println("copyErr.Err:", copyErr.Err)
+			fmt.Println("copyErr.Error():", copyErr.Error())
+			return
+		}
+
+		fmt.Println("err is not a CopyError:", err)
+		return
+	}
+
+	fmt.Println("Copy succeeded unexpectedly")
+
+	// Output:
+	// copyErr.Origin: source
+	// copyErr.Op: Resolve
+	// copyErr.Err: non-existent-reference: not found
+	// copyErr.Error(): failed to perform "Resolve" on source: non-existent-reference: not found
+}
diff --git a/extendedcopy_test.go b/extendedcopy_test.go
index af73349..47ea131 100644
--- a/extendedcopy_test.go
+++ b/extendedcopy_test.go
@@ -1830,20 +1830,153 @@ func TestExtendedCopyGraph_FilterArtifactTypeAndAnnotationWithMultipleRegex_Refe
 	verifyCopy(dst, copiedIndice, uncopiedIndice)
 }
 
-func TestExtededCopy_Error(t *testing.T) {
+func TestExtendedCopy_CopyError(t *testing.T) {
 	t.Run("src target is nil", func(t *testing.T) {
 		ctx := context.Background()
 		dst := memory.New()
-		if _, err := oras.ExtendedCopy(ctx, nil, "", dst, "", oras.DefaultExtendedCopyOptions); err == nil {
-			t.Errorf("Copy() error = %v, wantErr %v", err, true)
+		_, err := oras.ExtendedCopy(ctx, nil, "", dst, "", oras.DefaultExtendedCopyOptions)
+		if err == nil {
+			t.Errorf("ExtendedCopy() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("ExtendedCopy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
 		}
 	})
 
 	t.Run("dst target is nil", func(t *testing.T) {
 		ctx := context.Background()
 		src := memory.New()
-		if _, err := oras.ExtendedCopy(ctx, src, "", nil, "", oras.DefaultExtendedCopyOptions); err == nil {
-			t.Errorf("Copy() error = %v, wantErr %v", err, true)
+		_, err := oras.ExtendedCopy(ctx, src, "", nil, "", oras.DefaultExtendedCopyOptions)
+		if err == nil {
+			t.Fatalf("ExtendedCopy() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("ExtendedCopy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
 		}
 	})
+
+	t.Run("find predecessors error", func(t *testing.T) {
+		var errFindPredecessors = errors.New("find predecessors error")
+		ctx := context.Background()
+		src := memory.New()
+		dst := memory.New()
+		srcRef := "test"
+
+		// prepare test content
+		manifestDesc, err := oras.PackManifest(ctx, src, oras.PackManifestVersion1_1, "application/test", oras.PackManifestOptions{})
+		if err != nil {
+			t.Fatalf("failed to pack test content: %v", err)
+		}
+		if err := src.Tag(ctx, manifestDesc, srcRef); err != nil {
+			t.Fatalf("failed to tag test content on src: %v", err)
+		}
+
+		// test extended copy
+		opts := oras.DefaultExtendedCopyOptions
+		opts.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
+			return nil, errFindPredecessors
+		}
+		_, err = oras.ExtendedCopy(ctx, src, srcRef, dst, "", opts)
+		if err == nil {
+			t.Errorf("ExtendedCopy() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("ExtendedCopy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errFindPredecessors; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyError error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+
+	t.Run("tag error", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		dst := &badGraphTagger{
+			GraphTarget: memory.New(),
+		}
+		srcRef := "test"
+
+		// prepare test content
+		manifestDesc, err := oras.PackManifest(ctx, src, oras.PackManifestVersion1_1, "application/test", oras.PackManifestOptions{})
+		if err != nil {
+			t.Fatalf("failed to pack test content: %v", err)
+		}
+		if err := src.Tag(ctx, manifestDesc, srcRef); err != nil {
+			t.Fatalf("failed to tag test content on src: %v", err)
+		}
+
+		// test extended copy
+		_, err = oras.ExtendedCopy(ctx, src, srcRef, dst, "", oras.DefaultExtendedCopyOptions)
+		if err == nil {
+			t.Errorf("ExtendedCopy() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("ExtendedCopy() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+		if wantErr := errGraphTag; !errors.Is(copyErr.Err, wantErr) {
+			t.Errorf("CopyError error = %v, wantErr %v", copyErr.Err, wantErr)
+		}
+	})
+}
+
+func TestExtendedCopyGraph_CopyError(t *testing.T) {
+
+	t.Run("src target is nil", func(t *testing.T) {
+		ctx := context.Background()
+		dst := memory.New()
+		err := oras.ExtendedCopyGraph(ctx, nil, dst, ocispec.Descriptor{}, oras.DefaultExtendedCopyGraphOptions)
+		if err == nil {
+			t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("ExtendedCopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginSource; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+	})
+
+	t.Run("dst target is nil", func(t *testing.T) {
+		ctx := context.Background()
+		src := memory.New()
+		err := oras.ExtendedCopyGraph(ctx, src, nil, ocispec.Descriptor{}, oras.DefaultExtendedCopyGraphOptions)
+		if err == nil {
+			t.Errorf("ExtendedCopyGraph() error = %v, wantErr %v", err, true)
+		}
+		copyErr, ok := err.(*oras.CopyError)
+		if !ok {
+			t.Fatalf("ExtendedCopyGraph() error is not a CopyError: %v", err)
+		}
+		if want := oras.CopyErrorOriginDestination; copyErr.Origin != want {
+			t.Errorf("CopyError origin = %v, want %v", copyErr.Origin, want)
+		}
+	})
+
+}
+
+type badGraphTagger struct {
+	oras.GraphTarget
+}
+
+var errGraphTag = errors.New("graph tag error")
+
+func (bgt *badGraphTagger) Tag(_ context.Context, _ ocispec.Descriptor, _ string) error {
+	return errGraphTag
 }
diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go
index 89466e6..9b2c32e 100644
--- a/internal/platform/platform_test.go
+++ b/internal/platform/platform_test.go
@@ -344,9 +344,8 @@ func TestSelectManifest(t *testing.T) {
 		OS:           os_1,
 	}
 	_, err = SelectManifest(ctx, storage, root, &targetPlatform)
-	expected = fmt.Sprintf("fail to recognize platform from unknown config %s: expect %s", docker.MediaTypeConfig, ocispec.MediaTypeImageConfig)
-	if err.Error() != expected {
-		t.Fatalf("SelectManifest() error = %v, wantErr %v", err, expected)
+	if wantErr := errdef.ErrUnsupported; !errors.Is(err, wantErr) {
+		t.Errorf("SelectManifest() error = %v, wantErr %v", err, wantErr)
 	}
 
 	// generate test content with null config blob