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 1 commit
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
134 changes: 134 additions & 0 deletions manifest/ocischema/artifact_manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package ocischema

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 {
// 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 (m ArtifactManifest) References() []distribution.Descriptor {
references := make([]distribution.Descriptor, 0, 1+len(m.Blobs))
references = append(references, m.Blobs...)
references = append(references, m.Subject)
return references
}

// DeserializedArtifactManifest wraps ArtifactManifest with a copy of the original JSON.
// It satisfies the distribution.Manifest interface.
type DeserializedArtifactManifest struct {
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
}

// 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
}
144 changes: 144 additions & 0 deletions manifest/ocischema/artifact_manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ocischema

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

"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/manifest/manifestlist"
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 := 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")
}
})
}
6 changes: 0 additions & 6 deletions manifest/ocischema/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,6 @@ func (m DeserializedManifest) Payload() (string, []byte, error) {
return v1.MediaTypeImageManifest, m.canonical, nil
}

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

// validateManifest returns an error if the byte slice is invalid JSON or if it
// contains fields that belong to a index
func validateManifest(b []byte) error {
Expand Down
9 changes: 9 additions & 0 deletions manifest/ocischema/unknown_document.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ocischema

// unknownDocument represents a manifest, manifest list, or index that has not
// yet been validated. This type is used for validating byte content.
type unknownDocument struct {
MediaType string `json:"mediaType"` // used to recognize ArtifactManifest
Config interface{} `json:"config"` // used to recognize ImageManifest
Manifests interface{} `json:"manifests,omitempty"` // used to recognize index
}