Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: implement artifact manifest type #29

Merged
merged 5 commits into from
Oct 27, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions manifest/manifestlist/manifestlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@ func (m DeserializedManifestList) Payload() (string, []byte, error) {
// unknownDocument represents a manifest, manifest list, or index that has not
// yet been validated
type unknownDocument struct {
Config interface{} `json:"config,omitempty"`
Layers interface{} `json:"layers,omitempty"`
MediaType string `json:"mediaType,omitempty"`
Config interface{} `json:"config,omitempty"`
Layers interface{} `json:"layers,omitempty"`
}

// validateIndex returns an error if the byte slice is invalid JSON or if it
Expand All @@ -239,8 +240,11 @@ func validateIndex(b []byte) error {
if err := json.Unmarshal(b, &doc); err != nil {
return err
}
if doc.MediaType == v1.MediaTypeArtifactManifest && doc.Config == nil {
return errors.New("index: expected image manifest but found artifact manifest")
wangxiaoxuan273 marked this conversation as resolved.
Show resolved Hide resolved
}
if doc.Config != nil || doc.Layers != nil {
return errors.New("index: expected index but found manifest")
return errors.New("index: expected index but found image manifest")
}
return nil
}
144 changes: 144 additions & 0 deletions manifest/ociartifact/artifact_manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ociartifact

import (
"encoding/json"
"errors"
"fmt"

"github.com/distribution/distribution/v3"
"github.com/opencontainers/go-digest"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

func init() {
artifactFunc := func(b []byte) (distribution.Manifest, distribution.Descriptor, error) {
if err := validateArtifactManifest(b); err != nil {
return nil, distribution.Descriptor{}, err
}
m := new(DeserializedArtifactManifest)
err := m.UnmarshalJSON(b)
if err != nil {
return nil, distribution.Descriptor{}, err
}

dgst := digest.FromBytes(b)
return m, distribution.Descriptor{Digest: dgst, Size: int64(len(b)), MediaType: v1.MediaTypeArtifactManifest}, err
}
err := distribution.RegisterManifestSchema(v1.MediaTypeArtifactManifest, artifactFunc)
if err != nil {
panic(fmt.Sprintf("Unable to register artifact manifest: %s", err))
}
}

// ArtifactManifest defines an ocischema artifact manifest.
type ArtifactManifest struct {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why don't we use v1.Artifact?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this struct needs to satisfy the manifest interface, also there are some compatibility issues as v1.Artifact uses oci.Descriptor but distribution uses distribution.Descriptor.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, fair. Then there're a few disparities between the 2 structs:

  • artifactType is a required field, so we don't need omitempty
  • Better use *distribution.Descriptor for the optional subject

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I read the latest oci artifact manifest spec, artifactType is actually SHOULD.

Copy link
Author

@wangxiaoxuan273 wangxiaoxuan273 Oct 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed subject to pointer after offline discussion. This gives func (m Manifest) References() a better design since it's much easier to decide whether it is nil. Thanks Wei!

// MediaType must be application/vnd.oci.artifact.manifest.v1+json.
MediaType string `json:"mediaType"`

// ArtifactType contains the mediaType of the referenced artifact.
// If defined, the value MUST comply with RFC 6838, including the naming
// requirements in its section 4.2, and MAY be registered with IANA.
ArtifactType string `json:"artifactType,omitempty"`

// Blobs lists descriptors for the blobs referenced by the artifact.
Blobs []distribution.Descriptor `json:"blobs,omitempty"`

// Subject specifies the descriptor of another manifest. This value is
// used by the referrers API.
Subject distribution.Descriptor `json:"subject,omitempty"`

// Annotations contains arbitrary metadata for the artifact manifest.
Annotations map[string]string `json:"annotations,omitempty"`
}

// References returns the descriptors of this artifact manifest references.
func (am ArtifactManifest) References() []distribution.Descriptor {
var references []distribution.Descriptor
references = append(references, am.Blobs...)
// if Subject exists, append it to references, this part needs more design
if am.Subject.Digest != "" {
references = append(references, am.Subject)
}
return references
}

// DeserializedArtifactManifest wraps ArtifactManifest with a copy of the original JSON.
// It satisfies the distribution.Manifest interface.
type DeserializedArtifactManifest struct {
wangxiaoxuan273 marked this conversation as resolved.
Show resolved Hide resolved
ArtifactManifest

// canonical is the canonical byte representation of the ArtifactManifest.
canonical []byte
}

// ArtifactManifestFromStruct takes an ArtifactManifest structure, marshals it to JSON, and returns a
// DeserializedArtifactManifest which contains the manifest and its JSON representation.
func ArtifactManifestFromStruct(m ArtifactManifest) (*DeserializedArtifactManifest, error) {
var deserialized DeserializedArtifactManifest
deserialized.ArtifactManifest = m

var err error
deserialized.canonical, err = json.MarshalIndent(&m, "", " ")
return &deserialized, err
}

// UnmarshalJSON populates a new ArtifactManifest struct from JSON data.
func (m *DeserializedArtifactManifest) UnmarshalJSON(b []byte) error {
m.canonical = make([]byte, len(b))
// store manifest in canonical
copy(m.canonical, b)

// Unmarshal canonical JSON into an ArtifactManifest object
var manifest ArtifactManifest
if err := json.Unmarshal(m.canonical, &manifest); err != nil {
return err
}

if manifest.MediaType != v1.MediaTypeArtifactManifest {
return fmt.Errorf("mediaType in manifest should be '%s' not '%s'",
v1.MediaTypeArtifactManifest, manifest.MediaType)
}

m.ArtifactManifest = manifest

return nil
}

// MarshalJSON returns the contents of canonical. If canonical is empty,
// marshals the inner contents.
func (m *DeserializedArtifactManifest) MarshalJSON() ([]byte, error) {
if len(m.canonical) > 0 {
return m.canonical, nil
}

return nil, errors.New("JSON representation not initialized in DeserializedArtifactManifest")
}

// Payload returns the raw content of the artifact manifest. The contents can be used to
// calculate the content identifier.
func (m DeserializedArtifactManifest) Payload() (string, []byte, error) {
return v1.MediaTypeArtifactManifest, m.canonical, nil
}

// unknownDocument represents a manifest, manifest list, or index that has not
// yet been validated
type unknownDocument struct {
Config interface{} `json:"config,omitempty"`
Manifests interface{} `json:"manifests,omitempty"`
}

// validateArtifactManifest returns an error if the byte slice is invalid JSON or if it
// contains fields that belong to an index or an image manifest
func validateArtifactManifest(b []byte) error {
var doc unknownDocument
if err := json.Unmarshal(b, &doc); err != nil {
return err
}
if doc.Config != nil {
return errors.New("oci artifact manifest: expected artifact manifest but found image manifest")
}
if doc.Manifests != nil {
return errors.New("oci artifact manifest: expected artifact manifest but found index")
}
return nil
}
145 changes: 145 additions & 0 deletions manifest/ociartifact/artifact_manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package ociartifact

import (
"bytes"
"encoding/json"
"reflect"
"testing"

"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/manifest/manifestlist"
"github.com/distribution/distribution/v3/manifest/ocischema"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

// Example showing an artifact manifest for an example SBOM referencing an image,
// taken from https://github.com/opencontainers/image-spec/blob/main/artifact.md.
var expectedArtifactManifestSerialization = []byte(`{
"mediaType": "application/vnd.oci.artifact.manifest.v1+json",
"artifactType": "application/vnd.example.sbom.v1",
"blobs": [
{
"mediaType": "application/gzip",
"size": 123,
"digest": "sha256:87923725d74f4bfb94c9e86d64170f7521aad8221a5de834851470ca142da630"
}
],
"subject": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 1234,
"digest": "sha256:cc06a2839488b8bd2a2b99dcdc03d5cfd818eed72ad08ef3cc197aac64c0d0a0"
},
"annotations": {
"org.example.sbom.format": "json",
"org.opencontainers.artifact.created": "2022-01-01T14:42:55Z"
}
}`)

func makeTestArtifactManifest() ArtifactManifest {
return ArtifactManifest{
MediaType: v1.MediaTypeArtifactManifest,
ArtifactType: "application/vnd.example.sbom.v1",
Blobs: []distribution.Descriptor{
{
MediaType: "application/gzip",
Size: 123,
Digest: "sha256:87923725d74f4bfb94c9e86d64170f7521aad8221a5de834851470ca142da630",
},
},
Subject: distribution.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Size: 1234,
Digest: "sha256:cc06a2839488b8bd2a2b99dcdc03d5cfd818eed72ad08ef3cc197aac64c0d0a0",
},
Annotations: map[string]string{
"org.opencontainers.artifact.created": "2022-01-01T14:42:55Z",
"org.example.sbom.format": "json"},
}
}
func TestArtifactManifest(t *testing.T) {
testManifest := makeTestArtifactManifest()

// Test ArtifactManifestFromStruct()
deserialized, err := ArtifactManifestFromStruct(testManifest)
if err != nil {
t.Fatalf("error creating DeserializedArtifactManifest: %v", err)
}

// Test DeserializedArtifactManifest.Payload()
mediaType, canonical, _ := deserialized.Payload()
if mediaType != v1.MediaTypeArtifactManifest {
t.Fatalf("unexpected media type: %s", mediaType)
}

// Validate DeserializedArtifactManifest.canonical
p, err := json.MarshalIndent(&testManifest, "", " ")
if err != nil {
t.Fatalf("error marshaling manifest: %v", err)
}
if !bytes.Equal(p, canonical) {
t.Fatalf("manifest bytes not equal: %q != %q", string(canonical), string(p))
}
// Check that canonical field matches expected value.
if !bytes.Equal(expectedArtifactManifestSerialization, canonical) {
t.Fatalf("manifest bytes not equal: %q != %q", string(canonical), string(expectedArtifactManifestSerialization))
}

// Validate DeserializedArtifactManifest.ArtifactManifest
var unmarshalled DeserializedArtifactManifest
if err := json.Unmarshal(deserialized.canonical, &unmarshalled); err != nil {
t.Fatalf("error unmarshaling manifest: %v", err)
}
if !reflect.DeepEqual(&unmarshalled, deserialized) {
t.Fatalf("manifests are different after unmarshaling: %v != %v", unmarshalled, *deserialized)
}

// Test DeserializedArtifactManifest.References()
references := deserialized.References()
if len(references) != 2 {
t.Fatalf("unexpected number of references: %d", len(references))
}
}

func TestValidateArtifactManifest(t *testing.T) {
artifactManifest := ArtifactManifest{
MediaType: v1.MediaTypeArtifactManifest,
ArtifactType: "example/test",
Blobs: []distribution.Descriptor{{Size: 7}},
}
imageManifest := ocischema.Manifest{
Config: distribution.Descriptor{Size: 1},
Layers: []distribution.Descriptor{{Size: 2}},
}
index := manifestlist.ManifestList{
Manifests: []manifestlist.ManifestDescriptor{
{Descriptor: distribution.Descriptor{Size: 9}},
},
}
t.Run("valid", func(t *testing.T) {
b, err := json.Marshal(artifactManifest)
if err != nil {
t.Fatal("unexpected error marshaling manifest", err)
}
if err := validateArtifactManifest(b); err != nil {
t.Error("manifest should be valid", err)
}
})
t.Run("invalid_image_manifest", func(t *testing.T) {
b, err := json.Marshal(imageManifest)
if err != nil {
t.Fatal("unexpected error marshaling image manifest", err)
}
if err := validateArtifactManifest(b); err == nil {
t.Error("image manifest should not be valid")
}
})
t.Run("invalid_index", func(t *testing.T) {
b, err := json.Marshal(index)
if err != nil {
t.Fatal("unexpected error marshaling index", err)
}
if err := validateArtifactManifest(b); err == nil {
t.Error("index should not be valid")
}
})
}
7 changes: 6 additions & 1 deletion manifest/ocischema/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ func (m DeserializedManifest) Payload() (string, []byte, error) {
// unknownDocument represents a manifest, manifest list, or index that has not
// yet been validated
type unknownDocument struct {
MediaType string `json:"mediaType,omitempty"`
Config interface{} `json:"config,omitempty"`
Manifests interface{} `json:"manifests,omitempty"`
}

Expand All @@ -139,8 +141,11 @@ func validateManifest(b []byte) error {
if err := json.Unmarshal(b, &doc); err != nil {
return err
}
if doc.MediaType == v1.MediaTypeArtifactManifest && doc.Config == nil {
wangxiaoxuan273 marked this conversation as resolved.
Show resolved Hide resolved
return errors.New("oci image manifest: expected image manifest but found artifact manifest")
}
if doc.Manifests != nil {
return errors.New("ocimanifest: expected manifest but found index")
return errors.New("oci image manifest: expected manifest but found index")
}
return nil
}