Repository: oras-project/oras

Issue:
artifactType in index manifest and in its "manifests" descriptors
### What is the version of your ORAS CLI

1.3.0-beta.2

### What would you like to be added?

I've been experimenting with using the new `oras manifest index create` command to construct multi-platform index manifests for the new (currently in development) OCI Registry-based provider package distribution mechanism in [OpenTofu](https://opentofu.org/).

The index-building functionality broadly worked, but in order to give better feedback to users when they provide us the wrong kind of artifact we currently require the `artifactType` property to be populated in two locations:

1. In the index manifest's top-level `artifactType` property, we use a type that represents a provider version as a whole, agnostic of platform.
2. In each of the OpenTofu-relevant descriptors in the `manifests` property, we expect to find a copy of the `artifactType` property from the manifest itself, so that we can efficiently filter any manifests that are not relevant.

    (This particular situation is primarily aimed at forward compatibility with future changes to the manifest layout that we cannot anticipate today, in case a future requirement causes us to mix two different kinds of "image" artifact into the same index, with older versions of OpenTofu ignoring the newer ones they don't understand.)


For my problem 1, I'd propose adding an `--artifact-type` option to `oras manifest index create` which has similar meaning to the option of the same name on `oras push`, causing the given type to be written into the top-level `artifactType` property.

For my problem 2, I wonder if the code that builds the index manifest could copy the `artifactType` property from the target manifest into the corresponding descriptor, in a similar way to how it currently populates the `platform` property based on information from the manifest's config (or, if  https://github.com/oras-project/oras/issues/1538 is implemented, from some ORAS-specific annotations in the manifest.)

Overall then, I'd like to be able to:

- Construct one or more image manifests that each have our `artifactType` and the appropriate platform by running `oras push --artifact-type=application/vnd.opentofu.provider-target --artifact-platform $os/$arch ...`
- Construct the image manifest by running `oras manifest index create --artifact-type=application/vnd.opentofu.provider ...`
- Have the final index manifest include the relevant `artifactType` properties, so we can quickly reject/ignore irrelevant or incompatible artifacts based only on the index manifest. without fetching any image manifests:

```json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "artifactType": "application/vnd.opentofu.provider",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "artifactType": "application/vnd.opentofu.provider-target",
      "digest": "sha256:f22b608064cba5d2bcea544ea636acca254ff3dcfcf7e39f6218b302c07c0a80",
      "size": 616,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "artifactType": "application/vnd.opentofu.provider-target",
      "digest": "sha256:9d4fa52874a3beb018d3c22683b3c49c2e2821a29c5479454958a59b5d8fee9b",
      "size": 616,
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "artifactType": "application/vnd.opentofu.provider-target",
      "digest": "sha256:7e60f35171ad8a53cfe1b5e0bb771e464b748e9468e62151712947c16e5c9618",
      "size": 618,
      "platform": {
        "architecture": "amd64",
        "os": "windows"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "artifactType": "application/vnd.opentofu.provider-target",
      "digest": "sha256:e90dba706bdf891c1c9b5a83758061d64c9a4e7f7bea6b7895b6259fb27c6bd1",
      "size": 618,
      "platform": {
        "architecture": "arm64",
        "os": "windows"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "artifactType": "application/vnd.opentofu.provider-target",
      "digest": "sha256:d8635054545b76d40048e81d46a0620e49ff1c94831d864a37ab1ac3d4cefdec",
      "size": 617,
      "platform": {
        "architecture": "amd64",
        "os": "darwin"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "artifactType": "application/vnd.opentofu.provider-target",
      "digest": "sha256:7f591880d0cc947eb723dd5091ae360e7a1ba5d49e9d8478c6f6df59021c4b5c",
      "size": 617,
      "platform": {
        "architecture": "arm64",
        "os": "darwin"
      }
    }
  ]
}
```


### Why is this needed for ORAS?

OpenTofu's provider installer would like to be able to:

- Immediately reject any index manifest that doesn't have the artifact type `application/vnd.opentofu.provider`, to give users good feedback that they've selected the wrong artifact.
- Silently ignore any manifests in the index that don't have the artifact type `application/vnd.opentofu.provider-target`, so that a future version of OpenTofu could potentially introduce a new artifact type to meet an as-yet-unrealized need while allowing the publication of index manifests that are backward-compatible with older versions that only understand `application/vnd.opentofu.provider-target`.

We _could_ potentially meet these requirements by downloading each index manifest that has the appropriate platform and checking _its_ `artifactType`, but we'd prefer to be able to deal with this using only information in the index manifest.

### Are you willing to submit PRs to contribute to this feature?

- [x] Yes, I am willing to implement it.

Hints from issue comments:
@FeynmanZhou: This is a typical scenario of creating an image index for multi-plaform artifact. I am happy to see @apparentlymart proposed this idea from a real use case of OpenTofu. 

Adding the `artifactType` property to top-level index and each manifest also complies with OCI image spec v1.1. We may want to update the proposal doc to propose the changes https://github.com/oras-project/oras/blob/main/docs/proposals/multi-arch-image-mgmt.md

@apparentlymart I saw you marked that you are willing to contribute to this feature. Are you interested in driving the proposal doc update and implementation in the community? We can work together on this proposal and bring it to ORAS.@FeynmanZhou: @apparentlymart  There is another issue we raised in the OCI image spec which is kind of relevant to OpenTofu's multi-platform artifact scenario: https://github.com/opencontainers/image-spec/issues/1216@Wwwsylvia: Sounds good to me! @apparentlymart: Hi @FeynmanZhou! Sorry for the slow response.

I'm currently on vacation but I will follow up again once I'm back at work, next week. Thanks!@apparentlymart: Hello again! I'm back now and ready to discuss how I could help with this issue.

From @FeynmanZhou's comment I understood that perhaps I could help this move forward by opening a pull request to continue the work from https://github.com/oras-project/oras/pull/1514 by making some additions to that existing proposal, as opposed to starting a _new_ proposal. Did I understand that correctly?

---

Assuming that I _did_ understand correctly, here are some additional thoughts. Of course you can ignore this all if I misunderstood the intention of the earlier discussion. 😀 

The current proposal doesn't seem to directly discuss the format of the generated index manifest at all, so my initial instinct is to propose adding a new section after the existing "CLI Specs for new subcommands" section called "Index Manifest Structure" which would link to [the upstream image index specification](https://github.com/opencontainers/image-spec/blob/v1.1.1/image-index.md) and then define how each of the properties from that specification would be populated by the `oras manifest index create` and `oras manifest index update` subcommands.

I expect that most of that definition will be straightfoward, but would in particular include the following:

- The top-level `artifactType` is populated based on the `--artifact-type` option from the command line:

    - For `create`, the `artifactType` property is left unpopulated when that option isn't set, or populated with the value of the option when it is set.
    - For `update`, any existing `artifactType` property is _unchanged_ when that option isn't set, or overwritten with the value of the option when it is set.

        (Would it make sense to allow `-artifact-type=""` to explicitly unset it? I'm not sure yet if the CLI options framework ORAS uses allows distinguishing between unset vs. explicitly empty string.)

- The `artifactType` in each of the descriptors under `manifests` is automatically populated to match the corresponding property in the associated manifest, without any option to customize this behavior.

- Specific information on how the `platform` object in each descriptor under `manifests` is populated, based on whatever is decided in https://github.com/opencontainers/image-spec/issues/1216.

    (Perhaps for now the proposal would just document the _current_ experimental behavior of ORAS CLI, while waiting for that upstream question to be resolved? It seems like so far the use-case hasn't been well understood so I assume a decision on that question will not be made very soon.)

I would also add an additional item to each of the example blocks under "CLI Specs for new subcommands" that illustrates using `--artifact-type` option.

Does that seem like a good approach? Thanks!
@FeynmanZhou: @apparentlymart I think you understood perfectly. Feel free to raise a PR to add new sections to this [proposal doc](https://github.com/oras-project/oras/blob/main/docs/proposals/multi-arch-image-mgmt.md) based on your ideas listed above.

For this one, it is not in the scope of this issue. We could resolve it in the future until we have it aligned in the upstream OCI community.

> Specific information on how the platform object in each descriptor under manifests is populated, based on whatever is decided in https://github.com/opencontainers/image-spec/issues/1216.@apparentlymart: I have proposed some additions to the proposal text in https://github.com/oras-project/oras/pull/1696. Thanks!
@FeynmanZhou: Thank you @apparentlymart ! I will review it@Wwwsylvia: > Hello again! I'm back now and ready to discuss how I could help with this issue.
> 
> From [@FeynmanZhou](https://github.com/FeynmanZhou)'s comment I understood that perhaps I could help this move forward by opening a pull request to continue the work from [#1514](https://github.com/oras-project/oras/pull/1514) by making some additions to that existing proposal, as opposed to starting a _new_ proposal. Did I understand that correctly?
> 
> Assuming that I _did_ understand correctly, here are some additional thoughts. Of course you can ignore this all if I misunderstood the intention of the earlier discussion. 😀
> 
> The current proposal doesn't seem to directly discuss the format of the generated index manifest at all, so my initial instinct is to propose adding a new section after the existing "CLI Specs for new subcommands" section called "Index Manifest Structure" which would link to [the upstream image index specification](https://github.com/opencontainers/image-spec/blob/v1.1.1/image-index.md) and then define how each of the properties from that specification would be populated by the `oras manifest index create` and `oras manifest index update` subcommands.
> 
> I expect that most of that definition will be straightfoward, but would in particular include the following:
> 
> * The top-level `artifactType` is populated based on the `--artifact-type` option from the command line:
>   
>   * For `create`, the `artifactType` property is left unpopulated when that option isn't set, or populated with the value of the option when it is set.
>   * For `update`, any existing `artifactType` property is _unchanged_ when that option isn't set, or overwritten with the value of the option when it is set.
>     (Would it make sense to allow `-artifact-type=""` to explicitly unset it? I'm not sure yet if the CLI options framework ORAS uses allows distinguishing between unset vs. explicitly empty string.)
> * The `artifactType` in each of the descriptors under `manifests` is automatically populated to match the corresponding property in the associated manifest, without any option to customize this behavior.
> * Specific information on how the `platform` object in each descriptor under `manifests` is populated, based on whatever is decided in [Identifying Platform-Specific OCI Artifacts opencontainers/image-spec#1216](https://github.com/opencontainers/image-spec/issues/1216).
>   (Perhaps for now the proposal would just document the _current_ experimental behavior of ORAS CLI, while waiting for that upstream question to be resolved? It seems like so far the use-case hasn't been well understood so I assume a decision on that question will not be made very soon.)
> 
> I would also add an additional item to each of the example blocks under "CLI Specs for new subcommands" that illustrates using `--artifact-type` option.
> 
> Does that seem like a good approach? Thanks!

Thank you @apparentlymart , this looks great! I just checked your proposal PR and left some comments.@github-actions: This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 30 days.

Solution patch:
diff --git a/cmd/oras/root/manifest/index/create.go b/cmd/oras/root/manifest/index/create.go
index 5383f1d..3e13312 100644
--- a/cmd/oras/root/manifest/index/create.go
+++ b/cmd/oras/root/manifest/index/create.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"regexp"
 	"strings"
 
 	"github.com/opencontainers/image-spec/specs-go"
@@ -42,15 +43,21 @@ import (
 
 var maxConfigSize int64 = 4 * 1024 * 1024 // 4 MiB
 
+// mediaTypeRegexp is the regular expression pattern required for a valid
+// media type, as defined in the image spec schema:
+// - https://github.com/opencontainers/image-spec/blob/v1.1.1/schema/defs-descriptor.json#L7
+var mediaTypeRegexp = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$`)
+
 type createOptions struct {
 	option.Common
 	option.Target
 	option.Pretty
 	option.Annotation
 
-	sources    []string
-	extraRefs  []string
-	outputPath string
+	artifactType string
+	sources      []string
+	extraRefs    []string
+	outputPath   string
 }
 
 func createCmd() *cobra.Command {
@@ -75,6 +82,9 @@ Example - Create an index and push it with multiple tags:
 Example - Create and push an index with annotations:
   oras manifest index create localhost:5000/hello:v1 linux-amd64 --annotation "key=val"
 
+Example - Create an index with a specified artifact type:
+  oras manifest index create --artifact-type="application/vnd.example+type" localhost:5000/hello linux-amd64
+
 Example - Create an index and push to an OCI image layout folder 'layout-dir' and tag with 'v1':
   oras manifest index create layout-dir:v1 linux-amd64 sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 --oci-layout
   
@@ -97,6 +107,7 @@ Example - Create an index and output the index to stdout, auto push will be disa
 			return createIndex(cmd, opts)
 		},
 	}
+	cmd.Flags().StringVarP(&opts.artifactType, "artifact-type", "", "", "artifact type for overall index")
 	cmd.Flags().StringVarP(&opts.outputPath, "output", "o", "", "file `path` to write the created index to, use - for stdout")
 	option.ApplyFlags(&opts, cmd.Flags())
 	return oerrors.Command(cmd, &opts.Target)
@@ -108,6 +119,11 @@ func createIndex(cmd *cobra.Command, opts createOptions) error {
 	if err != nil {
 		return err
 	}
+	if opts.artifactType != "" {
+		if err := validateMediaType(opts.artifactType); err != nil {
+			return err
+		}
+	}
 	displayStatus, displayMetadata, displayContent := display.NewManifestIndexCreateHandler(opts.outputPath, opts.Printer, opts.Pretty.Pretty)
 	manifests, err := fetchSourceManifests(ctx, displayStatus, target, opts.sources)
 	if err != nil {
@@ -117,9 +133,10 @@ func createIndex(cmd *cobra.Command, opts createOptions) error {
 		Versioned: specs.Versioned{
 			SchemaVersion: 2,
 		},
-		MediaType:   ocispec.MediaTypeImageIndex,
-		Manifests:   manifests,
-		Annotations: opts.Annotations[option.AnnotationManifest],
+		MediaType:    ocispec.MediaTypeImageIndex,
+		ArtifactType: opts.artifactType,
+		Manifests:    manifests,
+		Annotations:  opts.Annotations[option.AnnotationManifest],
 	}
 	indexBytes, err := json.Marshal(index)
 	if err != nil {
@@ -165,12 +182,7 @@ func fetchSourceManifests(ctx context.Context, displayStatus status.ManifestInde
 	return resolved, nil
 }
 
-func getPlatform(ctx context.Context, target oras.ReadOnlyTarget, manifestBytes []byte) (*ocispec.Platform, error) {
-	// extract config descriptor
-	var manifest ocispec.Manifest
-	if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
-		return nil, err
-	}
+func getPlatform(ctx context.Context, target oras.ReadOnlyTarget, manifest *ocispec.Manifest) (*ocispec.Platform, error) {
 	// if config size is larger than 4 MiB, discontinue the fetch
 	if manifest.Config.Size > maxConfigSize {
 		return nil, fmt.Errorf("config size %v exceeds MaxBytes %v: %w", manifest.Config.Size, maxConfigSize, errdef.ErrSizeExceedsLimit)
@@ -216,10 +228,30 @@ func enrichDescriptor(ctx context.Context, target oras.ReadOnlyTarget, desc ocis
 	desc = descriptor.Plain(desc)
 	if descriptor.IsImageManifest(desc) {
 		var err error
-		desc.Platform, err = getPlatform(ctx, target, manifestBytes)
+		var manifest ocispec.Manifest
+		if err = json.Unmarshal(manifestBytes, &manifest); err != nil {
+			return ocispec.Descriptor{}, err
+		}
+		desc.Platform, err = getPlatform(ctx, target, &manifest)
 		if err != nil {
 			return ocispec.Descriptor{}, err
 		}
+		desc.ArtifactType = manifest.ArtifactType
+	} else if descriptor.IsIndex(desc) {
+		var index ocispec.Index
+		if err := json.Unmarshal(manifestBytes, &index); err != nil {
+			return ocispec.Descriptor{}, err
+		}
+		desc.ArtifactType = index.ArtifactType
 	}
 	return desc, nil
 }
+
+// validateMediaType checks whether mediaType uses valid media type syntax,
+// returning a non-nil error if not.
+func validateMediaType(mediaType string) error {
+	if !mediaTypeRegexp.MatchString(mediaType) {
+		return fmt.Errorf("%s: %w", mediaType, errdef.ErrInvalidMediaType)
+	}
+	return nil
+}
diff --git a/cmd/oras/root/manifest/index/update.go b/cmd/oras/root/manifest/index/update.go
index 9b9c41b..db61d01 100644
--- a/cmd/oras/root/manifest/index/update.go
+++ b/cmd/oras/root/manifest/index/update.go
@@ -41,6 +41,7 @@ type updateOptions struct {
 	option.Target
 	option.Pretty
 
+	artifactType    string
 	addArguments    []string
 	mergeArguments  []string
 	removeArguments []string
@@ -75,6 +76,12 @@ Example - Update an index and save it locally to index.json, auto push will be d
 
 Example - Update an index and output the index to stdout, auto push will be disabled:
   oras manifest index update localhost:5000/hello:v2 --remove sha256:99e4703fbf30916f549cd6bfa9cdbab614b5392fbe64fdee971359a77073cdf9 --output - --pretty
+
+Example - Update an index to use a different artifact type:
+  oras manifest index update localhost:5000/hello:v1 --artifact-type="application/vnd.example+type"
+
+Example - Update an index to remove any existing artifact type:
+  oras manifest index update localhost:5000/hello:v1 --artifact-type=""
   `,
 		Args: oerrors.CheckArgs(argument.Exactly(1), "the target index to update"),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
@@ -94,6 +101,7 @@ Example - Update an index and output the index to stdout, auto push will be disa
 		},
 	}
 	option.ApplyFlags(&opts, cmd.Flags())
+	cmd.Flags().StringVarP(&opts.artifactType, "artifact-type", "", "", "new artifact type for overall index")
 	cmd.Flags().StringArrayVarP(&opts.addArguments, "add", "", nil, "manifests to add to the index")
 	cmd.Flags().StringArrayVarP(&opts.mergeArguments, "merge", "", nil, "indexes to be merged into the index")
 	cmd.Flags().StringArrayVarP(&opts.removeArguments, "remove", "", nil, "manifests to remove from the index, must be digests")
@@ -116,11 +124,19 @@ func updateIndex(cmd *cobra.Command, opts updateOptions) error {
 	if err := opts.EnsureReferenceNotEmpty(cmd, true); err != nil {
 		return err
 	}
+	if opts.artifactType != "" {
+		if err := validateMediaType(opts.artifactType); err != nil {
+			return err
+		}
+	}
 	displayStatus, displayMetadata, displayContent := display.NewManifestIndexUpdateHandler(opts.outputPath, opts.Printer, opts.Pretty.Pretty)
 	index, err := fetchIndex(ctx, displayStatus, target, opts.Reference)
 	if err != nil {
 		return err
 	}
+	if cmd.Flags().Changed("artifact-type") {
+		index.ArtifactType = opts.artifactType
+	}
 	manifests, err := removeManifests(displayStatus, index.Manifests, target, opts)
 	if err != nil {
 		return err
@@ -259,7 +275,7 @@ func doRemoveManifests(originalManifests []ocispec.Descriptor, digestToRemove ma
 }
 
 func updateFlagsUsed(flags *pflag.FlagSet) bool {
-	return flags.Changed("add") || flags.Changed("remove") || flags.Changed("merge")
+	return flags.Changed("add") || flags.Changed("remove") || flags.Changed("merge") || flags.Changed("artifact-type")
 }
 
 func getPushPath(rawReference string, targetType string, reference string, path string) string {



Test patch:
diff --git a/cmd/oras/root/manifest/index/create_test.go b/cmd/oras/root/manifest/index/create_test.go
index 515302e..d92d5ca 100644
--- a/cmd/oras/root/manifest/index/create_test.go
+++ b/cmd/oras/root/manifest/index/create_test.go
@@ -139,3 +139,194 @@ func Test_fetchSourceManifests(t *testing.T) {
 		})
 	}
 }
+
+func Test_enrichDescriptor(t *testing.T) {
+	tests := []struct {
+		name              string
+		target            oras.ReadOnlyTarget
+		manifestBytes     []byte
+		manifestMediaType string
+		checkDesc         func(t *testing.T, gotDesc, inputDesc ocispec.Descriptor)
+		wantErr           bool
+	}{
+		{
+			name:   "child index, valid",
+			target: NewTestReadOnlyTarget("(unused)"),
+			manifestBytes: []byte(`
+				{
+					"schemaVersion": 2,
+					"mediaType": "application/vnd.oci.image.index.v1+json",
+					"artifactType": "application/vnd.example",
+					"manifests": [],
+					"annotations": {
+						"test-only": ""
+					}
+				}
+			`),
+			manifestMediaType: "application/vnd.oci.image.index.v1+json",
+			checkDesc: func(t *testing.T, gotDesc, inputDesc ocispec.Descriptor) {
+				if got, want := gotDesc.ArtifactType, "application/vnd.example"; got != want {
+					t.Errorf("ArtifactType = %s, want %s", got, want)
+				}
+			},
+			wantErr: false,
+		},
+		{
+			name:   "child manifest, valid with platform",
+			target: NewTestReadOnlyTarget(`{"architecture":"testarch","os":"testos"}`),
+			manifestBytes: []byte(`
+				{
+					"schemaVersion": 2,
+					"mediaType": "application/vnd.oci.image.manifest.v1+json",
+					"artifactType": "application/vnd.example",
+					"config": {
+						"mediaType": "application/vnd.oci.image.config.v1+json",
+						"digest": "sha256:5fefde2b739e2ff1976ecea4fb4f5e4827a1c424e9b1fb147ba5fd21b9197422",
+						"size": 41
+					},
+					"layers": [],
+					"annotations": {
+						"test-only": ""
+					}
+				}
+			`),
+			manifestMediaType: "application/vnd.oci.image.manifest.v1+json",
+			checkDesc: func(t *testing.T, gotDesc, inputDesc ocispec.Descriptor) {
+				if got, want := gotDesc.ArtifactType, "application/vnd.example"; got != want {
+					t.Errorf("ArtifactType = %s, want %s", got, want)
+				}
+				wantPlatform := &ocispec.Platform{
+					Architecture: "testarch",
+					OS:           "testos",
+				}
+				if !reflect.DeepEqual(gotDesc.Platform, wantPlatform) {
+					t.Errorf("Platform = %#v, want %#v", gotDesc.Platform, wantPlatform)
+				}
+			},
+			wantErr: false,
+		},
+		{
+			name:   "child manifest, valid without platform",
+			target: NewTestReadOnlyTarget(`intentionally not valid JSON`),
+			manifestBytes: []byte(`
+				{
+					"schemaVersion": 2,
+					"mediaType": "application/vnd.oci.image.manifest.v1+json",
+					"artifactType": "application/vnd.example",
+					"config": {
+						"mediaType": "application/vnd.other",
+						"digest": "sha256:dc889043956f34871cc04ae96e03efc29dfe2f582c26195a72dd4827f4dd830d",
+						"size": 28
+					},
+					"layers": [],
+					"annotations": {
+						"test-only": ""
+					}
+				}
+			`),
+			manifestMediaType: "application/vnd.oci.image.manifest.v1+json",
+			checkDesc: func(t *testing.T, gotDesc, inputDesc ocispec.Descriptor) {
+				if got, want := gotDesc.ArtifactType, "application/vnd.example"; got != want {
+					t.Errorf("ArtifactType = %s, want %s", got, want)
+				}
+				if gotDesc.Platform != nil {
+					t.Errorf("Platform = %#v, want nil", gotDesc.Platform)
+				}
+			},
+			wantErr: false,
+		},
+		{
+			name:              "child of unrecognized type",
+			target:            NewTestReadOnlyTarget("(unused)"),
+			manifestBytes:     []byte(`{}`),
+			manifestMediaType: "application/vnd.custom",
+			checkDesc: func(t *testing.T, gotDesc, inputDesc ocispec.Descriptor) {
+				if !reflect.DeepEqual(gotDesc, inputDesc) {
+					t.Errorf("result does not match input: got %#v, want %#v", gotDesc, inputDesc)
+				}
+			},
+			wantErr: false,
+		},
+		{
+			name:              "child manifest, invalid",
+			target:            NewTestReadOnlyTarget(`unused`),
+			manifestBytes:     []byte(`not actually a manifest`),
+			manifestMediaType: "application/vnd.oci.image.manifest.v1+json",
+			wantErr:           true,
+		},
+		{
+			name:              "child index, invalid",
+			target:            NewTestReadOnlyTarget(`unused`),
+			manifestBytes:     []byte(`not actually an index`),
+			manifestMediaType: "application/vnd.oci.image.index.v1+json",
+			wantErr:           true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			inputDesc := ocispec.Descriptor{
+				MediaType: tt.manifestMediaType,
+				Digest:    digest.FromBytes(tt.manifestBytes),
+				Size:      int64(len(tt.manifestBytes)),
+			}
+			gotDesc, err := enrichDescriptor(t.Context(), tt.target, inputDesc, tt.manifestBytes)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("enrichDescriptor() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if err != nil {
+				return // gotDesc is not valid when there's an error
+			}
+			// The fields we set in inputDesc should always be unchanged in
+			// the result.
+			if got, want := gotDesc.MediaType, inputDesc.MediaType; got != want {
+				t.Errorf("MediaType = %s, want %s", got, want)
+			}
+			if got, want := gotDesc.Digest, inputDesc.Digest; got != want {
+				t.Errorf("Digest = %s, want %s", got, want)
+			}
+			if got, want := gotDesc.Size, inputDesc.Size; got != want {
+				t.Errorf("Size = %d, want %d", got, want)
+			}
+			// Currently we do not enrich with annotations, though a future
+			// proposal might change that in which case the following should
+			// be removed in favor of specific tests in checkDesc.
+			//
+			// Discussion here:
+			//     https://github.com/oras-project/oras/pull/1696#issuecomment-2852473626
+			if len(gotDesc.Annotations) != 0 {
+				t.Errorf("Annotations = %#v, want none", gotDesc.Annotations)
+			}
+			// Other test-case-specific checks
+			if tt.checkDesc != nil {
+				tt.checkDesc(t, gotDesc, inputDesc)
+			}
+		})
+	}
+}
+
+func Test_validateMediaType(t *testing.T) {
+	tests := []struct {
+		input string
+		valid bool
+	}{
+		{"application/json", true},
+		{ocispec.MediaTypeEmptyJSON, true},
+		{ocispec.MediaTypeImageManifest, true},
+		{ocispec.MediaTypeImageIndex, true},
+		{"application/vnd.custom", true},
+		{"", false},
+		{"json", false},
+		{"application/-json", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			err := validateMediaType(tt.input)
+			if err == nil && !tt.valid {
+				t.Errorf("no error for invalid media type %q", tt.input)
+			}
+			if err != nil && tt.valid {
+				t.Errorf("unexpected error for valid media type %q: %s", tt.input, err)
+			}
+		})
+	}
+}
diff --git a/test/e2e/suite/command/manifest_index.go b/test/e2e/suite/command/manifest_index.go
index 3560f59..370deaf 100644
--- a/test/e2e/suite/command/manifest_index.go
+++ b/test/e2e/suite/command/manifest_index.go
@@ -116,6 +116,26 @@ var _ = Describe("1.1 registry users:", func() {
 			ValidateIndex(content, expectedManifests)
 		})
 
+		It("should copy artifactType from child manifests", func() {
+			testRepo := indexTestRepo("create", "child-artifact-type")
+			CopyZOTRepo(ImageRepo, testRepo)
+			artifactType := "application/vnd.test-example"
+			ORAS("push", "--artifact-type", artifactType,
+				RegistryRef(ZOTHost, testRepo, "with-artifact-type"),
+				"../../testdata/files/foobar/bar:application/octet-stream").
+				MatchKeyWords("Pushed",
+					"ArtifactType", artifactType).Exec()
+			ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "latest"), "with-artifact-type").
+				MatchKeyWords("Fetched", "with-artifact-type", "Pushed").Exec()
+			// verify
+			content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "latest")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(BeEmpty())
+			Expect(index.Manifests).To(HaveLen(1))
+			Expect(index.Manifests[0].ArtifactType).To(Equal(artifactType))
+		})
+
 		It("should create nested indexes", func() {
 			testRepo := indexTestRepo("create", "nested-index")
 			CopyZOTRepo(ImageRepo, testRepo)
@@ -150,6 +170,38 @@ var _ = Describe("1.1 registry users:", func() {
 			Expect(manifest.Annotations[key]).To(Equal(value))
 		})
 
+		It("should create index with artifactType", func() {
+			testRepo := indexTestRepo("create", "with-artifact-type")
+			artifactType := "application/vnd.test-example"
+			CopyZOTRepo(ImageRepo, testRepo)
+			ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "v1"), "--artifact-type", artifactType).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "v1")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal(artifactType))
+		})
+
+		It("should create index with artifactType while ignoring redundant options", func() {
+			testRepo := indexTestRepo("create", "with-artifact-type-multi")
+			ignoredArtifactType := "application/vnd.test-ignored"
+			artifactType := "application/vnd.test-example"
+			CopyZOTRepo(ImageRepo, testRepo)
+			ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "v1"), "--artifact-type", ignoredArtifactType, "--artifact-type", artifactType).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "v1")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal(artifactType))
+		})
+
+		It("should reject invalid artifactType", func() {
+			root := PrepareTempOCI(ImageRepo)
+			ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index"),
+				"--artifact-type", "invalid", string(multi_arch.LinuxAMD64.Digest)).
+				ExpectFailure().MatchErrKeyWords("invalid media type").Exec()
+		})
+
 		It("should output created index to file", func() {
 			testRepo := indexTestRepo("create", "output-to-file")
 			CopyZOTRepo(ImageRepo, testRepo)
@@ -234,6 +286,39 @@ var _ = Describe("1.1 registry users:", func() {
 			ValidateIndex(content, expectedManifests)
 		})
 
+		It("should update artifactType", func() {
+			testRepo := indexTestRepo("update", "artifact-type")
+			CopyZOTRepo(ImageRepo, testRepo)
+			// source index
+			ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "index"),
+				string(multi_arch.LinuxAMD64.Digest)).Exec()
+			// update index with artifact type
+			artifactType := "application/vnd.test-example"
+			ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "index"),
+				"--artifact-type", artifactType).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "index")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal(artifactType))
+		})
+
+		It("should remove artifactType", func() {
+			testRepo := indexTestRepo("update", "artifact-type-unset")
+			CopyZOTRepo(ImageRepo, testRepo)
+			// source index
+			ORAS("manifest", "index", "create", RegistryRef(ZOTHost, testRepo, "index"),
+				"--artifact-type", "application/vnd.old", string(multi_arch.LinuxAMD64.Digest)).Exec()
+			// update index with artifact type
+			ORAS("manifest", "index", "update", RegistryRef(ZOTHost, testRepo, "index"),
+				"--artifact-type", "").Exec()
+			// verify
+			content := ORAS("manifest", "fetch", RegistryRef(ZOTHost, testRepo, "index")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(BeEmpty())
+		})
+
 		It("should update and tag the updated index by --tag flag", func() {
 			testRepo := indexTestRepo("update", "tag-updated-index")
 			CopyZOTRepo(ImageRepo, testRepo)
@@ -406,6 +491,25 @@ var _ = Describe("OCI image layout users:", func() {
 			ValidateIndex(content, expectedManifests)
 		})
 
+		It("should copy artifactType from child manifests", func() {
+			root := PrepareTempOCI(ImageRepo)
+			artifactType := "application/vnd.test-example"
+			ORAS("push", "--artifact-type", artifactType,
+				Flags.Layout, LayoutRef(root, "with-artifact-type"),
+				"../../testdata/files/foobar/bar:application/octet-stream").
+				MatchKeyWords("Pushed",
+					"ArtifactType", artifactType).Exec()
+			ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "latest"), "with-artifact-type").
+				MatchKeyWords("Fetched", "with-artifact-type", "Pushed").Exec()
+			// verify
+			content := ORAS("manifest", "fetch", Flags.Layout, LayoutRef(root, "latest")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(BeEmpty())
+			Expect(index.Manifests).To(HaveLen(1))
+			Expect(index.Manifests[0].ArtifactType).To(Equal(artifactType))
+		})
+
 		It("should create nested indexes", func() {
 			root := PrepareTempOCI(ImageRepo)
 			indexRef := LayoutRef(root, "nested-index")
@@ -439,6 +543,31 @@ var _ = Describe("OCI image layout users:", func() {
 			Expect(manifest.Annotations[key]).To(Equal(value))
 		})
 
+		It("should create index with artifactType", func() {
+			root := PrepareTempOCI(ImageRepo)
+			indexRef := LayoutRef(root, "with-artifact-type")
+			artifactType := "application/vnd.test-example"
+			ORAS("manifest", "index", "create", Flags.Layout, indexRef, "--artifact-type", artifactType).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", Flags.Layout, indexRef).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal(artifactType))
+		})
+
+		It("should create index with artifactType while ignoring redundant options", func() {
+			root := PrepareTempOCI(ImageRepo)
+			indexRef := LayoutRef(root, "with-artifact-type-multi")
+			ignoredArtifactType := "application/vnd.test-ignored"
+			artifactType := "application/vnd.test-example"
+			ORAS("manifest", "index", "create", Flags.Layout, indexRef, "--artifact-type", ignoredArtifactType, "--artifact-type", artifactType).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", Flags.Layout, indexRef).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal(artifactType))
+		})
+
 		It("should output created index to file", func() {
 			root := PrepareTempOCI(ImageRepo)
 			indexRef := LayoutRef(root, "output-to-file")
@@ -519,6 +648,61 @@ var _ = Describe("OCI image layout users:", func() {
 			ValidateIndex(content, expectedManifests)
 		})
 
+		It("should update artifactType", func() {
+			root := PrepareTempOCI(ImageRepo)
+			// source index
+			ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index"),
+				string(multi_arch.LinuxAMD64.Digest)).Exec()
+			// update index with artifact type
+			artifactType := "application/vnd.test-example"
+			ORAS("manifest", "index", "update", Flags.Layout, LayoutRef(root, "index"),
+				"--artifact-type", artifactType).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", Flags.Layout, LayoutRef(root, "index")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal(artifactType))
+		})
+
+		It("should remove artifactType", func() {
+			root := PrepareTempOCI(ImageRepo)
+			// source index
+			ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index"),
+				"--artifact-type", "application/vnd.old", string(multi_arch.LinuxAMD64.Digest)).Exec()
+			// update index with artifact type
+			ORAS("manifest", "index", "update", Flags.Layout, LayoutRef(root, "index"),
+				"--artifact-type", "").Exec()
+			// verify
+			content := ORAS("manifest", "fetch", Flags.Layout, LayoutRef(root, "index")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(BeEmpty())
+		})
+
+		It("should preserve artifactType when not overridden", func() {
+			root := PrepareTempOCI(ImageRepo)
+			// source index
+			ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index"),
+				"--artifact-type", "application/vnd.old", string(multi_arch.LinuxAMD64.Digest)).Exec()
+			// update index with artifact type
+			ORAS("manifest", "index", "update", Flags.Layout, LayoutRef(root, "index")).Exec()
+			// verify
+			content := ORAS("manifest", "fetch", Flags.Layout, LayoutRef(root, "index")).Exec().Out.Contents()
+			var index ocispec.Index
+			Expect(json.Unmarshal(content, &index)).ShouldNot(HaveOccurred())
+			Expect(index.ArtifactType).To(Equal("application/vnd.old"))
+		})
+
+		It("should reject invalid artifactType", func() {
+			root := PrepareTempOCI(ImageRepo)
+			// source index
+			ORAS("manifest", "index", "create", Flags.Layout, LayoutRef(root, "index"),
+				"--artifact-type", "application/vnd.old", string(multi_arch.LinuxAMD64.Digest)).Exec()
+			// update index with invalid artifact type
+			ORAS("manifest", "index", "update", Flags.Layout, LayoutRef(root, "index"), "--artifact-type", "invalid").
+				ExpectFailure().MatchErrKeyWords("invalid media type").Exec()
+		})
+
 		It("should output updated index to file", func() {
 			root := PrepareTempOCI(ImageRepo)
 			filePath := filepath.Join(GinkgoT().TempDir(), "updatedIndex")