Repository: oras-project/oras
Pull request #748 (2023-01-13 03:40:25)

feat(oci-layout): support in `oras cp`
Related to #378 

codecov-commenter: # [Codecov](https://codecov.io/gh/oras-project/oras/pull/748?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) Report
> Merging [#748](https://codecov.io/gh/oras-project/oras/pull/748?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (071c792) into [main](https://codecov.io/gh/oras-project/oras/commit/9db20665a52d3a3a55e5ff349bfca88523aa9cff?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (9db2066) will **decrease** coverage by `8.67%`.
> The diff coverage is `12.38%`.

```diff
@@            Coverage Diff             @@
##             main     #748      +/-   ##
==========================================
- Coverage   73.46%   64.79%   -8.68%     
==========================================
  Files          17       18       +1     
  Lines         588      693     +105     
==========================================
+ Hits          432      449      +17     
- Misses        125      211      +86     
- Partials       31       33       +2     
```


| [Impacted Files](https://codecov.io/gh/oras-project/oras/pull/748?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) | Coverage Δ | |
|---|---|---|
| [cmd/oras/internal/option/target.go](https://codecov.io/gh/oras-project/oras/pull/748?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL3RhcmdldC5nbw==) | `12.38% <12.38%> (ø)` | |
| [cmd/oras/internal/option/remote.go](https://codecov.io/gh/oras-project/oras/pull/748?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL3JlbW90ZS5nbw==) | `69.87% <0.00%> (+2.40%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project)


Patch:
diff --git a/cmd/oras/cp.go b/cmd/oras/cp.go
index 7fc485d..1a81fdc 100644
--- a/cmd/oras/cp.go
+++ b/cmd/oras/cp.go
@@ -30,15 +30,12 @@ import (
 )
 
 type copyOptions struct {
-	src option.Remote
-	dst option.Remote
 	option.Common
 	option.Platform
-	recursive bool
+	option.BinaryTarget
 
+	recursive   bool
 	concurrency int
-	srcRef      string
-	dstRef      string
 	extraRefs   []string
 }
 
@@ -52,40 +49,45 @@ func copyCmd() *cobra.Command {
 
 ** This command is in preview and under development. **
 
-Example - Copy the artifact tagged with 'v1' from repository 'localhost:5000/net-monitor' to repository 'localhost:5000/net-monitor-copy' 
-  oras cp localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:v1
+Example - Copy an artifact between registries:
+  oras cp localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1
 
-Example - Copy the artifact tagged with 'v1' and its referrers from repository 'localhost:5000/net-monitor' to 'localhost:5000/net-monitor-copy'
-  oras cp -r localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:v1
+Example - Download an artifact into an OCI layout folder:
+  oras cp --to-oci-layout localhost:5000/net-monitor:v1 ./downloaded:v1
 
-Example - Copy the artifact tagged with 'v1' from repository 'localhost:5000/net-monitor' to 'localhost:5000/net-monitor-copy' with certain platform
-  oras cp --platform linux/arm/v5 localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:v1 
+Example - Upload an artifact from an OCI layout folder:
+  oras cp --from-oci-layout ./to-upload:v1 localhost:5000/net-monitor:v1
 
-Example - Copy the artifact tagged with 'v1' from repository 'localhost:5000/net-monitor' to 'localhost:5000/net-monitor-copy' with multiple tags
-  oras cp localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:v1,tag2,tag3
+Example - Upload an artifact from an OCI layout tar archive:
+  oras cp --from-oci-layout ./to-upload.tar:v1 localhost:5000/net-monitor:v1
 
-Example - Copy the artifact tagged with 'v1' from repository 'localhost:5000/net-monitor' to 'localhost:5000/net-monitor-copy' with multiple tags and concurrency level tuned
-  oras cp --concurrency 6 localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:v1,tag2,tag3
+Example - Copy an artifact and its referrers:
+  oras cp -r localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1
+
+Example - Copy certain platform of an artifact:
+  oras cp --platform linux/arm/v5 localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1
+
+Example - Copy an artifact with multiple tags:
+  oras cp localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:tag1,tag2,tag3
+
+Example - Copy an artifact with multiple tags with concurrency tuned:
+  oras cp --concurrency 10 localhost:5000/net-monitor:v1 localhost:5000/net-monitor-copy:tag1,tag2,tag3
 `,
 		Args: cobra.ExactArgs(2),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
+			opts.From.RawReference = args[0]
+			refs := strings.Split(args[1], ",")
+			opts.To.RawReference = refs[0]
+			opts.extraRefs = refs[1:]
 			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.srcRef = args[0]
-			refs := strings.Split(args[1], ",")
-			opts.dstRef = refs[0]
-			opts.extraRefs = refs[1:]
 			return runCopy(opts)
 		},
 	}
-
 	cmd.Flags().BoolVarP(&opts.recursive, "recursive", "r", false, "recursively copy the artifact and its referrer artifacts")
-	opts.src.ApplyFlagsWithPrefix(cmd.Flags(), "from", "source")
-	opts.dst.ApplyFlagsWithPrefix(cmd.Flags(), "to", "destination")
 	cmd.Flags().IntVarP(&opts.concurrency, "concurrency", "", 3, "concurrency level")
 	option.ApplyFlags(&opts, cmd.Flags())
-
 	return cmd
 }
 
@@ -93,13 +95,16 @@ func runCopy(opts copyOptions) error {
 	ctx, _ := opts.SetLoggerLevel()
 
 	// Prepare source
-	src, err := opts.src.NewRepository(opts.srcRef, opts.Common)
+	src, err := opts.From.NewReadonlyTarget(ctx, opts.Common)
 	if err != nil {
 		return err
 	}
+	if opts.From.Reference == "" {
+		return errors.NewErrInvalidReferenceStr(opts.From.RawReference)
+	}
 
 	// Prepare destination
-	dst, err := opts.dst.NewRepository(opts.dstRef, opts.Common)
+	dst, err := opts.To.NewTarget(opts.Common)
 	if err != nil {
 		return err
 	}
@@ -121,14 +126,10 @@ func runCopy(opts copyOptions) error {
 		return display.PrintStatus(desc, "Exists ", opts.Verbose)
 	}
 
-	if src.Reference.Reference == "" {
-		return errors.NewErrInvalidReference(src.Reference)
-	}
-
 	var desc ocispec.Descriptor
-	if ref := dst.Reference.Reference; ref == "" {
+	if ref := opts.To.Reference; ref == "" {
 		// push to the destination with digest only if no tag specified
-		desc, err = src.Resolve(ctx, src.Reference.Reference)
+		desc, err = src.Resolve(ctx, opts.From.Reference)
 		if err != nil {
 			return err
 		}
@@ -139,7 +140,7 @@ func runCopy(opts copyOptions) error {
 		}
 	} else {
 		if opts.recursive {
-			desc, err = oras.ExtendedCopy(ctx, src, opts.srcRef, dst, opts.dstRef, extendedCopyOptions)
+			desc, err = oras.ExtendedCopy(ctx, src, opts.From.Reference, dst, opts.To.Reference, extendedCopyOptions)
 		} else {
 			copyOptions := oras.CopyOptions{
 				CopyGraphOptions: extendedCopyOptions.CopyGraphOptions,
@@ -147,19 +148,19 @@ func runCopy(opts copyOptions) error {
 			if opts.Platform.Platform != nil {
 				copyOptions.WithTargetPlatform(opts.Platform.Platform)
 			}
-			desc, err = oras.Copy(ctx, src, opts.srcRef, dst, opts.dstRef, copyOptions)
+			desc, err = oras.Copy(ctx, src, opts.From.Reference, dst, opts.To.Reference, copyOptions)
 		}
 	}
 	if err != nil {
 		return err
 	}
 
-	fmt.Println("Copied", opts.srcRef, "=>", opts.dstRef)
+	fmt.Println("Copied", opts.From.AnnotatedReference(), "=>", opts.To.AnnotatedReference())
 
 	if len(opts.extraRefs) != 0 {
 		tagNOpts := oras.DefaultTagNOptions
 		tagNOpts.Concurrency = opts.concurrency
-		if err = oras.TagN(ctx, &display.TagManifestStatusPrinter{Repository: dst}, opts.dstRef, opts.extraRefs, tagNOpts); err != nil {
+		if _, err = oras.TagN(ctx, display.NewTagManifestStatusPrinter(dst), opts.To.Reference, opts.extraRefs, tagNOpts); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/oras/internal/display/print.go b/cmd/oras/internal/display/print.go
index 94d5953..675f491 100644
--- a/cmd/oras/internal/display/print.go
+++ b/cmd/oras/internal/display/print.go
@@ -22,8 +22,9 @@ import (
 	"sync"
 
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"oras.land/oras-go/v2"
 	"oras.land/oras-go/v2/content"
-	"oras.land/oras-go/v2/registry/remote"
+	"oras.land/oras-go/v2/registry"
 )
 
 var printLock sync.Mutex
@@ -74,14 +75,38 @@ func PrintSuccessorStatus(ctx context.Context, desc ocispec.Descriptor, status s
 	return nil
 }
 
-type TagManifestStatusPrinter struct {
-	*remote.Repository
+// NewTagManifestStatusPrinter creates a wrapper type for printing tag status.
+func NewTagManifestStatusPrinter(target oras.Target) oras.Target {
+	if repo, ok := target.(registry.Repository); ok {
+		return &tagManifestStatusForRepo{
+			Repository: repo,
+		}
+	}
+	return &tagManifestStatusForTarget{
+		Target: target,
+	}
+}
+
+type tagManifestStatusForRepo struct {
+	registry.Repository
 }
 
 // PushReference overrides Repository.PushReference method to print off which tag(s) were added successfully.
-func (p *TagManifestStatusPrinter) PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error {
+func (p *tagManifestStatusForRepo) PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error {
 	if err := p.Repository.PushReference(ctx, expected, content, reference); err != nil {
 		return err
 	}
 	return Print("Tagged", reference)
 }
+
+type tagManifestStatusForTarget struct {
+	oras.Target
+}
+
+// Tag tags a descriptor with a reference string.
+func (p *tagManifestStatusForTarget) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error {
+	if err := p.Target.Tag(ctx, desc, reference); err != nil {
+		return err
+	}
+	return Print("Tagged", reference)
+}
diff --git a/cmd/oras/internal/errors/errors.go b/cmd/oras/internal/errors/errors.go
index 2f211d1..e3c6e4c 100644
--- a/cmd/oras/internal/errors/errors.go
+++ b/cmd/oras/internal/errors/errors.go
@@ -23,5 +23,10 @@ import (
 
 // NewErrInvalidReference creates a new error based on the reference string.
 func NewErrInvalidReference(ref registry.Reference) error {
+	return NewErrInvalidReferenceStr(ref.String())
+}
+
+// NewErrInvalidReferenceStr creates a new error based on the reference string.
+func NewErrInvalidReferenceStr(ref string) error {
 	return fmt.Errorf("%s: invalid image reference, expecting <name:tag|name@digest>", ref)
 }
diff --git a/cmd/oras/internal/fileref/unix.go b/cmd/oras/internal/fileref/unix.go
index 82494f0..66d6b79 100644
--- a/cmd/oras/internal/fileref/unix.go
+++ b/cmd/oras/internal/fileref/unix.go
@@ -23,15 +23,15 @@ import (
 )
 
 // Parse parses file reference on unix.
-func Parse(reference string, defaultMediaType string) (filePath, mediaType string, err error) {
+func Parse(reference string, defaultMetadata string) (filePath, metadata string, err error) {
 	i := strings.LastIndex(reference, ":")
 	if i < 0 {
-		filePath, mediaType = reference, defaultMediaType
+		filePath, metadata = reference, defaultMetadata
 	} else {
-		filePath, mediaType = reference[:i], reference[i+1:]
+		filePath, metadata = reference[:i], reference[i+1:]
 	}
 	if filePath == "" {
 		return "", "", fmt.Errorf("found empty file path in %q", reference)
 	}
-	return filePath, mediaType, nil
+	return filePath, metadata, nil
 }
diff --git a/cmd/oras/internal/fileref/windows.go b/cmd/oras/internal/fileref/windows.go
index 9ea0597..19609cd 100644
--- a/cmd/oras/internal/fileref/windows.go
+++ b/cmd/oras/internal/fileref/windows.go
@@ -23,24 +23,24 @@ import (
 	"unicode"
 )
 
-// Parse parses file reference on windows.
-func Parse(reference string, defaultMediaType string) (filePath, mediaType string, err error) {
-	filePath, mediaType = doParse(reference, defaultMediaType)
+// Parse parses file reference into filePath and metadata.
+func Parse(reference string, defaultMetadata string) (filePath, metadata string, err error) {
+	filePath, metadata = doParse(reference, defaultMetadata)
 	if filePath == "" {
 		return "", "", fmt.Errorf("found empty file path in %q", reference)
 	}
-	if strings.ContainsAny(filePath, `<>:"|?*`) {
+	if strings.ContainsAny(filePath, `<>"|?*`) {
 		// Reference: https://learn.microsoft.com/windows/win32/fileio/naming-a-file#naming-conventions
 		return "", "", fmt.Errorf("reserved characters found in the file path: %s", filePath)
 	}
-	return filePath, mediaType, nil
+	return filePath, metadata, nil
 }
 
-func doParse(reference string, mediaType string) (filePath, mediatype string) {
+func doParse(reference string, defaultMetadata string) (filePath, metadata string) {
 	i := strings.LastIndex(reference, ":")
 	if i < 0 || (i == 1 && len(reference) > 2 && unicode.IsLetter(rune(reference[0])) && reference[2] == '\\') {
 		// Relative file path with disk prefix is NOT supported, e.g. `c:file1`
-		return reference, mediaType
+		return reference, defaultMetadata
 	}
 	return reference[:i], reference[i+1:]
 }
diff --git a/cmd/oras/internal/option/target.go b/cmd/oras/internal/option/target.go
new file mode 100644
index 0000000..0f5829e
--- /dev/null
+++ b/cmd/oras/internal/option/target.go
@@ -0,0 +1,187 @@
+/*
+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 option
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/spf13/pflag"
+	"oras.land/oras-go/v2"
+	"oras.land/oras-go/v2/content/oci"
+	"oras.land/oras-go/v2/registry"
+	"oras.land/oras/cmd/oras/internal/fileref"
+)
+
+const (
+	TargetTypeRemote    = "registry"
+	TargetTypeOCILayout = "oci-layout"
+)
+
+// Target struct contains flags and arguments specifying one registry or image
+// layout.
+type Target struct {
+	Remote
+	RawReference string
+	Type         string
+	Reference    string //contains tag or digest
+
+	isOCILayout bool
+}
+
+// ApplyFlags applies flags to a command flag set for unary target
+func (opts *Target) ApplyFlags(fs *pflag.FlagSet) {
+	opts.applyFlagsWithPrefix(fs, "", "")
+	opts.Remote.ApplyFlags(fs)
+}
+
+// AnnotatedReference returns full printable reference.
+func (opts *Target) AnnotatedReference() string {
+	return fmt.Sprintf("[%s] %s", opts.Type, opts.RawReference)
+}
+
+// applyFlagsWithPrefix applies flags to fs with prefix and description.
+// The complete form of the `target` flag is designed to be
+//
+//	--target type=<type>[[,<key>=<value>][...]]
+//
+// For better UX, the boolean flag `--oci-layout` is introduced as an alias of
+// `--target type=oci-layout`.
+// Since there is only one target type besides the default `registry` type,
+// the full form is not implemented until a new type comes in.
+func (opts *Target) applyFlagsWithPrefix(fs *pflag.FlagSet, prefix, description string) {
+	var (
+		flagPrefix string
+		noteSuffix string
+	)
+	if prefix != "" {
+		flagPrefix = prefix + "-"
+		noteSuffix = description + " "
+	}
+	fs.BoolVarP(&opts.isOCILayout, flagPrefix+"oci-layout", "", false, "Set "+noteSuffix+"target as an OCI image layout.")
+}
+
+// ApplyFlagsWithPrefix applies flags to a command flag set with a prefix string.
+// Commonly used for non-unary remote targets.
+func (opts *Target) ApplyFlagsWithPrefix(fs *pflag.FlagSet, prefix, description string) {
+	opts.applyFlagsWithPrefix(fs, prefix, description)
+	opts.Remote.ApplyFlagsWithPrefix(fs, prefix, description)
+}
+
+// Parse gets target options from user input.
+func (opts *Target) Parse() error {
+	switch {
+	case opts.isOCILayout:
+		opts.Type = TargetTypeOCILayout
+	default:
+		opts.Type = TargetTypeRemote
+	}
+	return nil
+}
+
+// parseOCILayoutReference parses the raw in format of <path>[:<tag>|@<digest>]
+func parseOCILayoutReference(raw string) (path string, ref string, err error) {
+	if idx := strings.LastIndex(raw, "@"); idx != -1 {
+		// `digest` found
+		path = raw[:idx]
+		ref = raw[idx+1:]
+	} else {
+		// find `tag`
+		path, ref, err = fileref.Parse(raw, "")
+	}
+	return
+}
+
+// NewTarget generates a new target based on opts.
+func (opts *Target) NewTarget(common Common) (oras.GraphTarget, error) {
+	switch opts.Type {
+	case TargetTypeOCILayout:
+		var path string
+		var err error
+		path, opts.Reference, err = parseOCILayoutReference(opts.RawReference)
+		if err != nil {
+			return nil, err
+		}
+		return oci.New(path)
+	case TargetTypeRemote:
+		repo, err := opts.NewRepository(opts.RawReference, common)
+		if err != nil {
+			return nil, err
+		}
+		opts.Reference = repo.Reference.Reference
+		return repo, nil
+	}
+	return nil, fmt.Errorf("unknown target type: %q", opts.Type)
+}
+
+// ReadOnlyGraphTagFinderTarget represents a read-only graph target with tag
+// finder capability.
+type ReadOnlyGraphTagFinderTarget interface {
+	oras.ReadOnlyGraphTarget
+	registry.TagLister
+}
+
+// NewReadonlyTargets generates a new read only target based on opts.
+func (opts *Target) NewReadonlyTarget(ctx context.Context, common Common) (ReadOnlyGraphTagFinderTarget, error) {
+	switch opts.Type {
+	case TargetTypeOCILayout:
+		var path string
+		var err error
+		path, opts.Reference, err = parseOCILayoutReference(opts.RawReference)
+		if err != nil {
+			return nil, err
+		}
+		info, err := os.Stat(path)
+		if err != nil {
+			return nil, err
+		}
+		if info.IsDir() {
+			return oci.NewFromFS(ctx, os.DirFS(path))
+		}
+		return oci.NewFromTar(ctx, path)
+	case TargetTypeRemote:
+		repo, err := opts.NewRepository(opts.RawReference, common)
+		if err != nil {
+			return nil, err
+		}
+		opts.Reference = repo.Reference.Reference
+		return repo, nil
+	}
+	return nil, fmt.Errorf("unknown target type: %q", opts.Type)
+}
+
+// BinaryTarget struct contains flags and arguments specifying two registries or
+// image layouts.
+type BinaryTarget struct {
+	From Target
+	To   Target
+}
+
+// ApplyFlags applies flags to a command flag set fs.
+func (opts *BinaryTarget) ApplyFlags(fs *pflag.FlagSet) {
+	opts.From.ApplyFlagsWithPrefix(fs, "from", "source")
+	opts.To.ApplyFlagsWithPrefix(fs, "to", "destination")
+}
+
+// Parse parses user-provided flags and arguments into option struct.
+func (opts *BinaryTarget) Parse() error {
+	if err := opts.From.Parse(); err != nil {
+		return err
+	}
+	return opts.To.Parse()
+}
diff --git a/cmd/oras/manifest/push.go b/cmd/oras/manifest/push.go
index 0c172ad..f9b17e3 100644
--- a/cmd/oras/manifest/push.go
+++ b/cmd/oras/manifest/push.go
@@ -168,7 +168,7 @@ func pushManifest(opts pushOptions) error {
 	}
 	display.Print("Pushed", opts.targetRef)
 	if len(opts.extraRefs) != 0 {
-		if _, err = oras.TagBytesN(ctx, &display.TagManifestStatusPrinter{Repository: repo}, mediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil {
+		if _, err = oras.TagBytesN(ctx, display.NewTagManifestStatusPrinter(repo), mediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/oras/push.go b/cmd/oras/push.go
index c4f64b4..3de0671 100644
--- a/cmd/oras/push.go
+++ b/cmd/oras/push.go
@@ -199,7 +199,7 @@ func runPush(opts pushOptions) error {
 		}
 		tagBytesNOpts := oras.DefaultTagBytesNOptions
 		tagBytesNOpts.Concurrency = opts.concurrency
-		if _, err = oras.TagBytesN(ctx, &display.TagManifestStatusPrinter{Repository: dst}, root.MediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil {
+		if _, err = oras.TagBytesN(ctx, display.NewTagManifestStatusPrinter(dst), root.MediaType, contentBytes, opts.extraRefs, tagBytesNOpts); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/oras/tag/tag.go b/cmd/oras/tag/tag.go
index bcad5f7..d5ad5db 100644
--- a/cmd/oras/tag/tag.go
+++ b/cmd/oras/tag/tag.go
@@ -82,5 +82,6 @@ func tagManifest(opts tagOptions) error {
 
 	tagNOpts := oras.DefaultTagNOptions
 	tagNOpts.Concurrency = opts.concurrency
-	return oras.TagN(ctx, &display.TagManifestStatusPrinter{Repository: repo}, opts.srcRef, opts.targetRefs, tagNOpts)
+	_, err = oras.TagN(ctx, display.NewTagManifestStatusPrinter(repo), opts.srcRef, opts.targetRefs, tagNOpts)
+	return err
 }
diff --git a/go.mod b/go.mod
index 72f96c1..1e022af 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/spf13/cobra v1.6.1
 	github.com/spf13/pflag v1.0.5
 	gopkg.in/yaml.v3 v3.0.1
-	oras.land/oras-go/v2 v2.0.0-rc.6
+	oras.land/oras-go/v2 v2.0.0-20230112153040-29509026fb7f
 )
 
 require (
diff --git a/go.sum b/go.sum
index df3f861..68d3bd0 100644
--- a/go.sum
+++ b/go.sum
@@ -64,5 +64,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
 gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
-oras.land/oras-go/v2 v2.0.0-rc.6 h1:jGWysqm8flq+X0Vj8bZ6rkASAqTab5k18Mx9hEjFc8g=
-oras.land/oras-go/v2 v2.0.0-rc.6/go.mod h1:iVExH1NxrccIxjsiq17L91WCZ4KIw6jVQyCLsZsu1gc=
+oras.land/oras-go/v2 v2.0.0-20230112153040-29509026fb7f h1:A28WmSZajK0Mo48Ts6hjF0Tu6vwvK8Tw9CeFdCaQU+s=
+oras.land/oras-go/v2 v2.0.0-20230112153040-29509026fb7f/go.mod h1:iVExH1NxrccIxjsiq17L91WCZ4KIw6jVQyCLsZsu1gc=


Test patch:
diff --git a/cmd/oras/internal/fileref/windows_test.go b/cmd/oras/internal/fileref/windows_test.go
index 3cd60b2..c828404 100644
--- a/cmd/oras/internal/fileref/windows_test.go
+++ b/cmd/oras/internal/fileref/windows_test.go
@@ -82,6 +82,8 @@ func TestParse(t *testing.T) {
 		wantMediatype string
 		wantErr       bool
 	}{
+		{"valid file name", args{`c:\some-folder\test`, ""}, `c:\some-folder\test`, "", false},
+		{"valid file name and media type", args{`c:\some-folder\test`, "type"}, `c:\some-folder\test`, "type", false},
 		{"no input", args{"", ""}, "", "", true},
 		{"empty file name", args{":", ""}, "", "", true},
 		{"reserved character1 in file name", args{"<", "a"}, "", "", true},
@@ -97,7 +99,6 @@ func TestParse(t *testing.T) {
 		{"reserved character4 in file name, with media type", args{`":`, "a"}, "", "", true},
 		{"reserved character5 in file name, with media type", args{"|:", "a"}, "", "", true},
 		{"reserved character6 in file name, with media type", args{"?:", "a"}, "", "", true},
-		{"reserved character7 in file name, with media type", args{"::", "a"}, "", "", true},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/cmd/oras/internal/option/target_test.go b/cmd/oras/internal/option/target_test.go
new file mode 100644
index 0000000..d01eead
--- /dev/null
+++ b/cmd/oras/internal/option/target_test.go
@@ -0,0 +1,77 @@
+/*
+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 option
+
+import (
+	"testing"
+)
+
+func TestTarget_Parse_oci(t *testing.T) {
+	opts := Target{isOCILayout: true}
+
+	if err := opts.Parse(); err != nil {
+		t.Errorf("Target.Parse() error = %v", err)
+	}
+	if opts.Type != TargetTypeOCILayout {
+		t.Errorf("Target.Parse() failed, got %q, want %q", opts.Type, TargetTypeOCILayout)
+	}
+}
+
+func TestTarget_Parse_remote(t *testing.T) {
+	opts := Target{isOCILayout: false}
+	if err := opts.Parse(); err != nil {
+		t.Errorf("Target.Parse() error = %v", err)
+	}
+	if opts.Type != TargetTypeRemote {
+		t.Errorf("Target.Parse() failed, got %q, want %q", opts.Type, TargetTypeRemote)
+	}
+}
+
+func Test_parseOCILayoutReference(t *testing.T) {
+	type args struct {
+		raw string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		want1   string
+		wantErr bool
+	}{
+		{"Empty input", args{raw: ""}, "", "", true},
+		{"Empty path and tag", args{raw: ":"}, "", "", true},
+		{"Empty path and digest", args{raw: "@"}, "", "", false},
+		{"Empty digest", args{raw: "path@"}, "path", "", false},
+		{"Empty tag", args{raw: "path:"}, "path", "", false},
+		{"path and digest", args{raw: "path@digest"}, "path", "digest", false},
+		{"path and tag", args{raw: "path:tag"}, "path", "tag", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, got1, err := parseOCILayoutReference(tt.args.raw)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("parseOCILayoutReference() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("parseOCILayoutReference() got = %v, want %v", got, tt.want)
+			}
+			if got1 != tt.want1 {
+				t.Errorf("parseOCILayoutReference() got1 = %v, want %v", got1, tt.want1)
+			}
+		})
+	}
+}
diff --git a/cmd/oras/internal/option/target_windows_test.go b/cmd/oras/internal/option/target_windows_test.go
new file mode 100644
index 0000000..8066408
--- /dev/null
+++ b/cmd/oras/internal/option/target_windows_test.go
@@ -0,0 +1,52 @@
+//go:build windows
+
+/*
+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 option
+
+import (
+	"testing"
+)
+
+func Test_parseOCILayoutReference_windows(t *testing.T) {
+	type args struct {
+		raw string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		want1   string
+		wantErr bool
+	}{
+		{"path and tag", args{raw: `C:\some-folder:tag`}, `C:\some-folder`, "tag", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, got1, err := parseOCILayoutReference(tt.args.raw)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("parseOCILayoutReference() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("parseOCILayoutReference() got = %v, want %v", got, tt.want)
+			}
+			if got1 != tt.want1 {
+				t.Errorf("parseOCILayoutReference() got1 = %v, want %v", got1, tt.want1)
+			}
+		})
+	}
+}