diff --git a/manifest/ociartifact/manifest.go b/manifest/ociartifact/manifest.go new file mode 100644 index 00000000000..fa96302ff51 --- /dev/null +++ b/manifest/ociartifact/manifest.go @@ -0,0 +1,117 @@ +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) { + m := new(DeserializedManifest) + 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)) + } +} + +// Manifest defines an ocischema artifact manifest. +type Manifest 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 Manifest) References() []distribution.Descriptor { + var references []distribution.Descriptor + references = append(references, m.Blobs...) + if m.Subject != nil { + references = append(references, *m.Subject) + } + return references +} + +// DeserializedManifest wraps Manifest with a copy of the original JSON. +// It satisfies the distribution.Manifest interface. +type DeserializedManifest struct { + Manifest + + // canonical is the canonical byte representation of the Manifest. + canonical []byte +} + +// FromStruct takes an Manifest structure, marshals it to JSON, and returns a +// DeserializedManifest which contains the manifest and its JSON representation. +func FromStruct(m Manifest) (*DeserializedManifest, error) { + var deserialized DeserializedManifest + deserialized.Manifest = m + + var err error + deserialized.canonical, err = json.MarshalIndent(&m, "", " ") + return &deserialized, err +} + +// UnmarshalJSON populates a new Manifest struct from JSON data. +func (m *DeserializedManifest) UnmarshalJSON(b []byte) error { + m.canonical = make([]byte, len(b)) + // store manifest in canonical + copy(m.canonical, b) + + // Unmarshal canonical JSON into an Manifest object + var manifest Manifest + 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.Manifest = manifest + + return nil +} + +// MarshalJSON returns the contents of canonical. If canonical is empty, +// marshals the inner contents. +func (m *DeserializedManifest) MarshalJSON() ([]byte, error) { + if len(m.canonical) > 0 { + return m.canonical, nil + } + + return nil, errors.New("JSON representation not initialized in DeserializedManifest") +} + +// Payload returns the raw content of the artifact manifest. The contents can be used to +// calculate the content identifier. +func (m DeserializedManifest) Payload() (string, []byte, error) { + return v1.MediaTypeArtifactManifest, m.canonical, nil +} diff --git a/manifest/ociartifact/manifest_test.go b/manifest/ociartifact/manifest_test.go new file mode 100644 index 00000000000..9652f373b58 --- /dev/null +++ b/manifest/ociartifact/manifest_test.go @@ -0,0 +1,99 @@ +package ociartifact + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + + "github.com/distribution/distribution/v3" + 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 makeTestManifest() Manifest { + return Manifest{ + 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 := makeTestManifest() + + // Test FromStruct() + deserialized, err := FromStruct(testManifest) + if err != nil { + t.Fatalf("error creating DeserializedManifest: %v", err) + } + + // Test DeserializedManifest.Payload() + mediaType, canonical, _ := deserialized.Payload() + if mediaType != v1.MediaTypeArtifactManifest { + t.Fatalf("unexpected media type: %s", mediaType) + } + + // Validate DeserializedManifest.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 DeserializedManifest.Manifest + var unmarshalled DeserializedManifest + 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 DeserializedManifest.References() + references := deserialized.References() + if len(references) != 2 { + t.Fatalf("unexpected number of references: %d", len(references)) + } +}