summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-16 17:06:25 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-16 17:06:25 +0000
commitf115bb55d7eec53ad9ce2505dec9a7e0eed12536 (patch)
tree5c161bdd6ad6304914773103edbbe16d403d3d18 /internal
parentInitial commit. (diff)
downloadgolang-github-containers-image-f115bb55d7eec53ad9ce2505dec9a7e0eed12536.tar.xz
golang-github-containers-image-f115bb55d7eec53ad9ce2505dec9a7e0eed12536.zip
Adding upstream version 5.29.2.upstream/5.29.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'internal')
-rw-r--r--internal/blobinfocache/blobinfocache.go70
-rw-r--r--internal/blobinfocache/types.go53
-rw-r--r--internal/image/common_test.go53
-rw-r--r--internal/image/docker_list.go34
-rw-r--r--internal/image/docker_schema1.go257
-rw-r--r--internal/image/docker_schema1_test.go722
-rw-r--r--internal/image/docker_schema2.go413
-rw-r--r--internal/image/docker_schema2_test.go726
-rw-r--r--internal/image/fixtures/oci1-all-media-types-config.json161
-rw-r--r--internal/image/fixtures/oci1-all-media-types-to-schema2-config.json161
-rw-r--r--internal/image/fixtures/oci1-all-media-types-to-schema2.json41
-rw-r--r--internal/image/fixtures/oci1-all-media-types.json41
-rw-r--r--internal/image/fixtures/oci1-artifact.json43
-rw-r--r--internal/image/fixtures/oci1-config-extra-fields.json158
-rw-r--r--internal/image/fixtures/oci1-config.json1
-rw-r--r--internal/image/fixtures/oci1-extra-config-fields.json43
-rw-r--r--internal/image/fixtures/oci1-invalid-media-type.json15
-rw-r--r--internal/image/fixtures/oci1-to-schema1.json1
-rw-r--r--internal/image/fixtures/oci1-to-schema2-config.json1
-rw-r--r--internal/image/fixtures/oci1-to-schema2.json37
-rw-r--r--internal/image/fixtures/oci1.encrypted.json43
-rw-r--r--internal/image/fixtures/oci1.json43
-rw-r--r--internal/image/fixtures/schema1-for-oci-config.json29
-rw-r--r--internal/image/fixtures/schema1-to-oci1-config.json82
-rw-r--r--internal/image/fixtures/schema1-to-oci1.json41
-rw-r--r--internal/image/fixtures/schema1-to-schema2-config.json163
-rw-r--r--internal/image/fixtures/schema1-to-schema2.json41
-rw-r--r--internal/image/fixtures/schema1.json62
-rw-r--r--internal/image/fixtures/schema2-all-media-types-to-oci1.json36
-rw-r--r--internal/image/fixtures/schema2-all-media-types.json36
-rw-r--r--internal/image/fixtures/schema2-config.json1
-rw-r--r--internal/image/fixtures/schema2-invalid-media-type.json36
-rw-r--r--internal/image/fixtures/schema2-to-oci1-config.json105
-rw-r--r--internal/image/fixtures/schema2-to-oci1.json30
-rw-r--r--internal/image/fixtures/schema2-to-schema1-by-docker.json116
-rw-r--r--internal/image/fixtures/schema2.json36
-rw-r--r--internal/image/manifest.go121
-rw-r--r--internal/image/manifest_test.go71
-rw-r--r--internal/image/memory.go64
-rw-r--r--internal/image/oci.go336
-rw-r--r--internal/image/oci_index.go34
-rw-r--r--internal/image/oci_test.go891
-rw-r--r--internal/image/sourced.go134
-rw-r--r--internal/image/unparsed.go119
-rw-r--r--internal/imagedestination/impl/compat.go101
-rw-r--r--internal/imagedestination/impl/helpers.go25
-rw-r--r--internal/imagedestination/impl/helpers_test.go29
-rw-r--r--internal/imagedestination/impl/properties.go72
-rw-r--r--internal/imagedestination/stubs/put_blob_partial.go52
-rw-r--r--internal/imagedestination/stubs/signatures.go50
-rw-r--r--internal/imagedestination/stubs/stubs.go27
-rw-r--r--internal/imagedestination/wrapper.go96
-rw-r--r--internal/imagesource/impl/compat.go55
-rw-r--r--internal/imagesource/impl/layer_infos.go23
-rw-r--r--internal/imagesource/impl/properties.go27
-rw-r--r--internal/imagesource/impl/signatures.go19
-rw-r--r--internal/imagesource/stubs/get_blob_at.go52
-rw-r--r--internal/imagesource/stubs/stubs.go28
-rw-r--r--internal/imagesource/wrapper.go56
-rw-r--r--internal/iolimits/iolimits.go58
-rw-r--r--internal/iolimits/iolimits_test.go37
-rw-r--r--internal/manifest/common.go72
-rw-r--r--internal/manifest/common_test.go91
-rw-r--r--internal/manifest/docker_schema2.go15
-rw-r--r--internal/manifest/docker_schema2_list.go314
-rw-r--r--internal/manifest/docker_schema2_list_test.go109
-rw-r--r--internal/manifest/errors.go56
-rw-r--r--internal/manifest/list.go131
-rw-r--r--internal/manifest/list_test.go161
-rw-r--r--internal/manifest/manifest.go167
-rw-r--r--internal/manifest/manifest_test.go134
-rw-r--r--internal/manifest/oci_index.go446
-rw-r--r--internal/manifest/oci_index_test.go265
-rw-r--r--internal/manifest/testdata/non-json.manifest.jsonbin0 -> 411 bytes
-rw-r--r--internal/manifest/testdata/oci1.index.zstd-selection.json66
-rw-r--r--internal/manifest/testdata/oci1.index.zstd-selection2.json96
-rw-r--r--internal/manifest/testdata/oci1index.json31
-rw-r--r--internal/manifest/testdata/ocilist-variants.json67
-rw-r--r--internal/manifest/testdata/ociv1.artifact.json10
-rw-r--r--internal/manifest/testdata/ociv1.image.index.json31
-rw-r--r--internal/manifest/testdata/ociv1.manifest.json30
-rw-r--r--internal/manifest/testdata/ociv1nomime.artifact.json9
-rw-r--r--internal/manifest/testdata/ociv1nomime.image.index.json30
-rw-r--r--internal/manifest/testdata/ociv1nomime.manifest.json29
l---------internal/manifest/testdata/schema2-to-schema1-by-docker.json1
-rw-r--r--internal/manifest/testdata/schema2list-variants.json44
-rw-r--r--internal/manifest/testdata/schema2list.json72
-rw-r--r--internal/manifest/testdata/unknown-version.manifest.json5
-rw-r--r--internal/manifest/testdata/v2list.manifest.json56
-rw-r--r--internal/manifest/testdata/v2s1-invalid-signatures.manifest.json11
-rw-r--r--internal/manifest/testdata/v2s1-unsigned.manifest.json28
-rw-r--r--internal/manifest/testdata/v2s1.manifest.json44
-rw-r--r--internal/manifest/testdata/v2s2.manifest.json26
-rw-r--r--internal/manifest/testdata/v2s2nomime.manifest.json10
-rw-r--r--internal/manifest/testdata_info_test.go12
-rw-r--r--internal/pkg/platform/platform_matcher.go197
-rw-r--r--internal/pkg/platform/platform_matcher_test.go61
-rw-r--r--internal/private/private.go164
-rw-r--r--internal/putblobdigest/put_blob_digest.go57
-rw-r--r--internal/putblobdigest/put_blob_digest_test.go74
-rw-r--r--internal/rootless/rootless.go25
-rw-r--r--internal/set/set.go52
-rw-r--r--internal/set/set_test.go77
-rw-r--r--internal/signature/signature.go102
-rw-r--r--internal/signature/signature_test.go94
-rw-r--r--internal/signature/sigstore.go87
-rw-r--r--internal/signature/sigstore_test.go71
-rw-r--r--internal/signature/simple.go29
-rw-r--r--internal/signature/simple_test.go36
l---------internal/signature/testdata/simple.signature1
-rw-r--r--internal/signer/signer.go47
-rw-r--r--internal/signer/signer_test.go87
-rw-r--r--internal/streamdigest/fixtures/Hello.uncompressed1
-rw-r--r--internal/streamdigest/stream_digest.go40
-rw-r--r--internal/streamdigest/stream_digest_test.go36
-rw-r--r--internal/testing/explicitfilepath-tmpdir/tmpdir.go29
-rw-r--r--internal/testing/gpgagent/gpg_agent.go16
-rw-r--r--internal/testing/mocks/image_reference.go56
-rw-r--r--internal/testing/mocks/image_source.go47
-rw-r--r--internal/testing/mocks/image_transport.go24
-rw-r--r--internal/testing/mocks/unparsed_image.go31
-rw-r--r--internal/tmpdir/tmpdir.go44
-rw-r--r--internal/tmpdir/tmpdir_test.go54
-rw-r--r--internal/unparsedimage/wrapper.go38
-rw-r--r--internal/uploadreader/upload_reader.go61
-rw-r--r--internal/uploadreader/upload_reader_test.go34
-rw-r--r--internal/useragent/useragent.go6
127 files changed, 10955 insertions, 0 deletions
diff --git a/internal/blobinfocache/blobinfocache.go b/internal/blobinfocache/blobinfocache.go
new file mode 100644
index 0000000..2767c39
--- /dev/null
+++ b/internal/blobinfocache/blobinfocache.go
@@ -0,0 +1,70 @@
+package blobinfocache
+
+import (
+ "github.com/containers/image/v5/pkg/compression"
+ compressiontypes "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ digest "github.com/opencontainers/go-digest"
+)
+
+// FromBlobInfoCache returns a BlobInfoCache2 based on a BlobInfoCache, returning the original
+// object if it implements BlobInfoCache2, or a wrapper which discards compression information
+// if it only implements BlobInfoCache.
+func FromBlobInfoCache(bic types.BlobInfoCache) BlobInfoCache2 {
+ if bic2, ok := bic.(BlobInfoCache2); ok {
+ return bic2
+ }
+ return &v1OnlyBlobInfoCache{
+ BlobInfoCache: bic,
+ }
+}
+
+type v1OnlyBlobInfoCache struct {
+ types.BlobInfoCache
+}
+
+func (bic *v1OnlyBlobInfoCache) Open() {
+}
+
+func (bic *v1OnlyBlobInfoCache) Close() {
+}
+
+func (bic *v1OnlyBlobInfoCache) RecordDigestCompressorName(anyDigest digest.Digest, compressorName string) {
+}
+
+func (bic *v1OnlyBlobInfoCache) CandidateLocations2(transport types.ImageTransport, scope types.BICTransportScope, digest digest.Digest, canSubstitute bool) []BICReplacementCandidate2 {
+ return nil
+}
+
+// CandidateLocationsFromV2 converts a slice of BICReplacementCandidate2 to a slice of
+// types.BICReplacementCandidate, dropping compression information.
+func CandidateLocationsFromV2(v2candidates []BICReplacementCandidate2) []types.BICReplacementCandidate {
+ candidates := make([]types.BICReplacementCandidate, 0, len(v2candidates))
+ for _, c := range v2candidates {
+ candidates = append(candidates, types.BICReplacementCandidate{
+ Digest: c.Digest,
+ Location: c.Location,
+ })
+ }
+ return candidates
+}
+
+// OperationAndAlgorithmForCompressor returns CompressionOperation and CompressionAlgorithm
+// values suitable for inclusion in a types.BlobInfo structure, based on the name of the
+// compression algorithm, or Uncompressed, or UnknownCompression. This is typically used by
+// TryReusingBlob() implementations to set values in the BlobInfo structure that they return
+// upon success.
+func OperationAndAlgorithmForCompressor(compressorName string) (types.LayerCompression, *compressiontypes.Algorithm, error) {
+ switch compressorName {
+ case Uncompressed:
+ return types.Decompress, nil, nil
+ case UnknownCompression:
+ return types.PreserveOriginal, nil, nil
+ default:
+ algo, err := compression.AlgorithmByName(compressorName)
+ if err == nil {
+ return types.Compress, &algo, nil
+ }
+ return types.PreserveOriginal, nil, err
+ }
+}
diff --git a/internal/blobinfocache/types.go b/internal/blobinfocache/types.go
new file mode 100644
index 0000000..429d682
--- /dev/null
+++ b/internal/blobinfocache/types.go
@@ -0,0 +1,53 @@
+package blobinfocache
+
+import (
+ "github.com/containers/image/v5/types"
+ digest "github.com/opencontainers/go-digest"
+)
+
+const (
+ // Uncompressed is the value we store in a blob info cache to indicate that we know that
+ // the blob in the corresponding location is not compressed.
+ Uncompressed = "uncompressed"
+ // UnknownCompression is the value we store in a blob info cache to indicate that we don't
+ // know if the blob in the corresponding location is compressed (and if so, how) or not.
+ UnknownCompression = "unknown"
+)
+
+// BlobInfoCache2 extends BlobInfoCache by adding the ability to track information about what kind
+// of compression was applied to the blobs it keeps information about.
+type BlobInfoCache2 interface {
+ types.BlobInfoCache
+
+ // Open() sets up the cache for future accesses, potentially acquiring costly state. Each Open() must be paired with a Close().
+ // Note that public callers may call the types.BlobInfoCache operations without Open()/Close().
+ Open()
+ // Close destroys state created by Open().
+ Close()
+
+ // RecordDigestCompressorName records a compressor for the blob with the specified digest,
+ // or Uncompressed or UnknownCompression.
+ // WARNING: Only call this with LOCALLY VERIFIED data; don’t record a compressor for a
+ // digest just because some remote author claims so (e.g. because a manifest says so);
+ // otherwise the cache could be poisoned and cause us to make incorrect edits to type
+ // information in a manifest.
+ RecordDigestCompressorName(anyDigest digest.Digest, compressorName string)
+ // CandidateLocations2 returns a prioritized, limited, number of blobs and their locations (if known)
+ // that could possibly be reused within the specified (transport scope) (if they still
+ // exist, which is not guaranteed).
+ //
+ // If !canSubstitute, the returned cadidates will match the submitted digest exactly; if
+ // canSubstitute, data from previous RecordDigestUncompressedPair calls is used to also look
+ // up variants of the blob which have the same uncompressed digest.
+ //
+ // The CompressorName fields in returned data must never be UnknownCompression.
+ CandidateLocations2(transport types.ImageTransport, scope types.BICTransportScope, digest digest.Digest, canSubstitute bool) []BICReplacementCandidate2
+}
+
+// BICReplacementCandidate2 is an item returned by BlobInfoCache2.CandidateLocations2.
+type BICReplacementCandidate2 struct {
+ Digest digest.Digest
+ CompressorName string // either the Name() of a known pkg/compression.Algorithm, or Uncompressed or UnknownCompression
+ UnknownLocation bool // is true when `Location` for this blob is not set
+ Location types.BICLocationReference // not set if UnknownLocation is set to `true`
+}
diff --git a/internal/image/common_test.go b/internal/image/common_test.go
new file mode 100644
index 0000000..d66fb41
--- /dev/null
+++ b/internal/image/common_test.go
@@ -0,0 +1,53 @@
+package image
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ compressiontypes "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/slices"
+)
+
+// assertJSONEqualsFixture tests that jsonBytes is structurally equal to fixture,
+// possibly ignoring ignoreFields
+func assertJSONEqualsFixture(t *testing.T, jsonBytes []byte, fixture string, ignoreFields ...string) {
+ var contents map[string]any
+ err := json.Unmarshal(jsonBytes, &contents)
+ require.NoError(t, err)
+
+ fixtureBytes, err := os.ReadFile(filepath.Join("fixtures", fixture))
+ require.NoError(t, err)
+ var fixtureContents map[string]any
+
+ err = json.Unmarshal(fixtureBytes, &fixtureContents)
+ require.NoError(t, err)
+ for _, f := range ignoreFields {
+ delete(contents, f)
+ delete(fixtureContents, f)
+ }
+ assert.Equal(t, fixtureContents, contents)
+}
+
+// layerInfosWithCryptoOperation returns a copy of input where CryptoOperation is set to op
+func layerInfosWithCryptoOperation(input []types.BlobInfo, op types.LayerCrypto) []types.BlobInfo {
+ res := slices.Clone(input)
+ for i := range res {
+ res[i].CryptoOperation = op
+ }
+ return res
+}
+
+// layerInfosWithCompressionEdits returns a copy of input where CompressionOperation and CompressionAlgorithm is set to op and algo
+func layerInfosWithCompressionEdits(input []types.BlobInfo, op types.LayerCompression, algo *compressiontypes.Algorithm) []types.BlobInfo {
+ res := slices.Clone(input)
+ for i := range res {
+ res[i].CompressionOperation = op
+ res[i].CompressionAlgorithm = algo
+ }
+ return res
+}
diff --git a/internal/image/docker_list.go b/internal/image/docker_list.go
new file mode 100644
index 0000000..617a451
--- /dev/null
+++ b/internal/image/docker_list.go
@@ -0,0 +1,34 @@
+package image
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/containers/image/v5/internal/manifest"
+ "github.com/containers/image/v5/types"
+)
+
+func manifestSchema2FromManifestList(ctx context.Context, sys *types.SystemContext, src types.ImageSource, manblob []byte) (genericManifest, error) {
+ list, err := manifest.Schema2ListFromManifest(manblob)
+ if err != nil {
+ return nil, fmt.Errorf("parsing schema2 manifest list: %w", err)
+ }
+ targetManifestDigest, err := list.ChooseInstance(sys)
+ if err != nil {
+ return nil, fmt.Errorf("choosing image instance: %w", err)
+ }
+ manblob, mt, err := src.GetManifest(ctx, &targetManifestDigest)
+ if err != nil {
+ return nil, fmt.Errorf("fetching target platform image selected from manifest list: %w", err)
+ }
+
+ matches, err := manifest.MatchesDigest(manblob, targetManifestDigest)
+ if err != nil {
+ return nil, fmt.Errorf("computing manifest digest: %w", err)
+ }
+ if !matches {
+ return nil, fmt.Errorf("Image manifest does not match selected manifest digest %s", targetManifestDigest)
+ }
+
+ return manifestInstanceFromBlob(ctx, sys, src, manblob, mt)
+}
diff --git a/internal/image/docker_schema1.go b/internal/image/docker_schema1.go
new file mode 100644
index 0000000..3ef8e14
--- /dev/null
+++ b/internal/image/docker_schema1.go
@@ -0,0 +1,257 @@
+package image
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+type manifestSchema1 struct {
+ m *manifest.Schema1
+}
+
+func manifestSchema1FromManifest(manifestBlob []byte) (genericManifest, error) {
+ m, err := manifest.Schema1FromManifest(manifestBlob)
+ if err != nil {
+ return nil, err
+ }
+ return &manifestSchema1{m: m}, nil
+}
+
+// manifestSchema1FromComponents builds a new manifestSchema1 from the supplied data.
+func manifestSchema1FromComponents(ref reference.Named, fsLayers []manifest.Schema1FSLayers, history []manifest.Schema1History, architecture string) (genericManifest, error) {
+ m, err := manifest.Schema1FromComponents(ref, fsLayers, history, architecture)
+ if err != nil {
+ return nil, err
+ }
+ return &manifestSchema1{m: m}, nil
+}
+
+func (m *manifestSchema1) serialize() ([]byte, error) {
+ return m.m.Serialize()
+}
+
+func (m *manifestSchema1) manifestMIMEType() string {
+ return manifest.DockerV2Schema1SignedMediaType
+}
+
+// ConfigInfo returns a complete BlobInfo for the separate config object, or a BlobInfo{Digest:""} if there isn't a separate object.
+// Note that the config object may not exist in the underlying storage in the return value of UpdatedImage! Use ConfigBlob() below.
+func (m *manifestSchema1) ConfigInfo() types.BlobInfo {
+ return m.m.ConfigInfo()
+}
+
+// ConfigBlob returns the blob described by ConfigInfo, iff ConfigInfo().Digest != ""; nil otherwise.
+// The result is cached; it is OK to call this however often you need.
+func (m *manifestSchema1) ConfigBlob(context.Context) ([]byte, error) {
+ return nil, nil
+}
+
+// OCIConfig returns the image configuration as per OCI v1 image-spec. Information about
+// layers in the resulting configuration isn't guaranteed to be returned to due how
+// old image manifests work (docker v2s1 especially).
+func (m *manifestSchema1) OCIConfig(ctx context.Context) (*imgspecv1.Image, error) {
+ v2s2, err := m.convertToManifestSchema2(ctx, &types.ManifestUpdateOptions{})
+ if err != nil {
+ return nil, err
+ }
+ return v2s2.OCIConfig(ctx)
+}
+
+// LayerInfos returns a list of BlobInfos of layers referenced by this image, in order (the root layer first, and then successive layered layers).
+// The Digest field is guaranteed to be provided; Size may be -1.
+// WARNING: The list may contain duplicates, and they are semantically relevant.
+func (m *manifestSchema1) LayerInfos() []types.BlobInfo {
+ return manifestLayerInfosToBlobInfos(m.m.LayerInfos())
+}
+
+// EmbeddedDockerReferenceConflicts whether a Docker reference embedded in the manifest, if any, conflicts with destination ref.
+// It returns false if the manifest does not embed a Docker reference.
+// (This embedding unfortunately happens for Docker schema1, please do not add support for this in any new formats.)
+func (m *manifestSchema1) EmbeddedDockerReferenceConflicts(ref reference.Named) bool {
+ // This is a bit convoluted: We can’t just have a "get embedded docker reference" method
+ // and have the “does it conflict” logic in the generic copy code, because the manifest does not actually
+ // embed a full docker/distribution reference, but only the repo name and tag (without the host name).
+ // So we would have to provide a “return repo without host name, and tag” getter for the generic code,
+ // which would be very awkward. Instead, we do the matching here in schema1-specific code, and all the
+ // generic copy code needs to know about is reference.Named and that a manifest may need updating
+ // for some destinations.
+ name := reference.Path(ref)
+ var tag string
+ if tagged, isTagged := ref.(reference.NamedTagged); isTagged {
+ tag = tagged.Tag()
+ } else {
+ tag = ""
+ }
+ return m.m.Name != name || m.m.Tag != tag
+}
+
+// Inspect returns various information for (skopeo inspect) parsed from the manifest and configuration.
+func (m *manifestSchema1) Inspect(context.Context) (*types.ImageInspectInfo, error) {
+ return m.m.Inspect(nil)
+}
+
+// UpdatedImageNeedsLayerDiffIDs returns true iff UpdatedImage(options) needs InformationOnly.LayerDiffIDs.
+// This is a horribly specific interface, but computing InformationOnly.LayerDiffIDs can be very expensive to compute
+// (most importantly it forces us to download the full layers even if they are already present at the destination).
+func (m *manifestSchema1) UpdatedImageNeedsLayerDiffIDs(options types.ManifestUpdateOptions) bool {
+ return (options.ManifestMIMEType == manifest.DockerV2Schema2MediaType || options.ManifestMIMEType == imgspecv1.MediaTypeImageManifest)
+}
+
+// UpdatedImage returns a types.Image modified according to options.
+// This does not change the state of the original Image object.
+func (m *manifestSchema1) UpdatedImage(ctx context.Context, options types.ManifestUpdateOptions) (types.Image, error) {
+ copy := manifestSchema1{m: manifest.Schema1Clone(m.m)}
+
+ // We have 2 MIME types for schema 1, which are basically equivalent (even the un-"Signed" MIME type will be rejected if there isn’t a signature; so,
+ // handle conversions between them by doing nothing.
+ if options.ManifestMIMEType != manifest.DockerV2Schema1MediaType && options.ManifestMIMEType != manifest.DockerV2Schema1SignedMediaType {
+ converted, err := convertManifestIfRequiredWithUpdate(ctx, options, map[string]manifestConvertFn{
+ imgspecv1.MediaTypeImageManifest: copy.convertToManifestOCI1,
+ manifest.DockerV2Schema2MediaType: copy.convertToManifestSchema2Generic,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if converted != nil {
+ return converted, nil
+ }
+ }
+
+ // No conversion required, update manifest
+ if options.LayerInfos != nil {
+ if err := copy.m.UpdateLayerInfos(options.LayerInfos); err != nil {
+ return nil, err
+ }
+ }
+ if options.EmbeddedDockerReference != nil {
+ copy.m.Name = reference.Path(options.EmbeddedDockerReference)
+ if tagged, isTagged := options.EmbeddedDockerReference.(reference.NamedTagged); isTagged {
+ copy.m.Tag = tagged.Tag()
+ } else {
+ copy.m.Tag = ""
+ }
+ }
+
+ return memoryImageFromManifest(&copy), nil
+}
+
+// convertToManifestSchema2Generic returns a genericManifest implementation converted to manifest.DockerV2Schema2MediaType.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestSchema1 object.
+//
+// We need this function just because a function returning an implementation of the genericManifest
+// interface is not automatically assignable to a function type returning the genericManifest interface
+func (m *manifestSchema1) convertToManifestSchema2Generic(ctx context.Context, options *types.ManifestUpdateOptions) (genericManifest, error) {
+ return m.convertToManifestSchema2(ctx, options)
+}
+
+// convertToManifestSchema2 returns a genericManifest implementation converted to manifest.DockerV2Schema2MediaType.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestSchema1 object.
+//
+// Based on github.com/docker/docker/distribution/pull_v2.go
+func (m *manifestSchema1) convertToManifestSchema2(_ context.Context, options *types.ManifestUpdateOptions) (*manifestSchema2, error) {
+ uploadedLayerInfos := options.InformationOnly.LayerInfos
+ layerDiffIDs := options.InformationOnly.LayerDiffIDs
+
+ if len(m.m.ExtractedV1Compatibility) == 0 {
+ // What would this even mean?! Anyhow, the rest of the code depends on FSLayers[0] and ExtractedV1Compatibility[0] existing.
+ return nil, fmt.Errorf("Cannot convert an image with 0 history entries to %s", manifest.DockerV2Schema2MediaType)
+ }
+ if len(m.m.ExtractedV1Compatibility) != len(m.m.FSLayers) {
+ return nil, fmt.Errorf("Inconsistent schema 1 manifest: %d history entries, %d fsLayers entries", len(m.m.ExtractedV1Compatibility), len(m.m.FSLayers))
+ }
+ if uploadedLayerInfos != nil && len(uploadedLayerInfos) != len(m.m.FSLayers) {
+ return nil, fmt.Errorf("Internal error: uploaded %d blobs, but schema1 manifest has %d fsLayers", len(uploadedLayerInfos), len(m.m.FSLayers))
+ }
+ if layerDiffIDs != nil && len(layerDiffIDs) != len(m.m.FSLayers) {
+ return nil, fmt.Errorf("Internal error: collected %d DiffID values, but schema1 manifest has %d fsLayers", len(layerDiffIDs), len(m.m.FSLayers))
+ }
+
+ var convertedLayerUpdates []types.BlobInfo // Only used if options.LayerInfos != nil
+ if options.LayerInfos != nil {
+ if len(options.LayerInfos) != len(m.m.FSLayers) {
+ return nil, fmt.Errorf("Error converting image: layer edits for %d layers vs %d existing layers",
+ len(options.LayerInfos), len(m.m.FSLayers))
+ }
+ convertedLayerUpdates = []types.BlobInfo{}
+ }
+
+ // Build a list of the diffIDs for the non-empty layers.
+ diffIDs := []digest.Digest{}
+ var layers []manifest.Schema2Descriptor
+ for v1Index := len(m.m.ExtractedV1Compatibility) - 1; v1Index >= 0; v1Index-- {
+ v2Index := (len(m.m.ExtractedV1Compatibility) - 1) - v1Index
+
+ if !m.m.ExtractedV1Compatibility[v1Index].ThrowAway {
+ var size int64
+ if uploadedLayerInfos != nil {
+ size = uploadedLayerInfos[v2Index].Size
+ }
+ var d digest.Digest
+ if layerDiffIDs != nil {
+ d = layerDiffIDs[v2Index]
+ }
+ layers = append(layers, manifest.Schema2Descriptor{
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: size,
+ Digest: m.m.FSLayers[v1Index].BlobSum,
+ })
+ if options.LayerInfos != nil {
+ convertedLayerUpdates = append(convertedLayerUpdates, options.LayerInfos[v2Index])
+ }
+ diffIDs = append(diffIDs, d)
+ }
+ }
+ configJSON, err := m.m.ToSchema2Config(diffIDs)
+ if err != nil {
+ return nil, err
+ }
+ configDescriptor := manifest.Schema2Descriptor{
+ MediaType: "application/vnd.docker.container.image.v1+json",
+ Size: int64(len(configJSON)),
+ Digest: digest.FromBytes(configJSON),
+ }
+
+ if options.LayerInfos != nil {
+ options.LayerInfos = convertedLayerUpdates
+ }
+ return manifestSchema2FromComponents(configDescriptor, nil, configJSON, layers), nil
+}
+
+// convertToManifestOCI1 returns a genericManifest implementation converted to imgspecv1.MediaTypeImageManifest.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestSchema1 object.
+func (m *manifestSchema1) convertToManifestOCI1(ctx context.Context, options *types.ManifestUpdateOptions) (genericManifest, error) {
+ // We can't directly convert to OCI, but we can transitively convert via a Docker V2.2 Distribution manifest
+ m2, err := m.convertToManifestSchema2(ctx, options)
+ if err != nil {
+ return nil, err
+ }
+
+ return m2.convertToManifestOCI1(ctx, options)
+}
+
+// SupportsEncryption returns if encryption is supported for the manifest type
+func (m *manifestSchema1) SupportsEncryption(context.Context) bool {
+ return false
+}
+
+// CanChangeLayerCompression returns true if we can compress/decompress layers with mimeType in the current image
+// (and the code can handle that).
+// NOTE: Even if this returns true, the relevant format might not accept all compression algorithms; the set of accepted
+// algorithms depends not on the current format, but possibly on the target of a conversion (if UpdatedImage converts
+// to a different manifest format).
+func (m *manifestSchema1) CanChangeLayerCompression(mimeType string) bool {
+ return true // There are no MIME types in the manifest, so we must assume a valid image.
+}
diff --git a/internal/image/docker_schema1_test.go b/internal/image/docker_schema1_test.go
new file mode 100644
index 0000000..ee3f311
--- /dev/null
+++ b/internal/image/docker_schema1_test.go
@@ -0,0 +1,722 @@
+package image
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/types"
+ digest "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/slices"
+)
+
+var schema1FixtureLayerInfos = []types.BlobInfo{
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: 74876245,
+ Digest: "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4",
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: 1239,
+ Digest: "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a",
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: 78339724,
+ Digest: "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e",
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: 76857203,
+ Digest: "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6",
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: 25923380,
+ Digest: "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788",
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Size: 23511300,
+ Digest: "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d",
+ },
+}
+
+var schema1FixtureLayerDiffIDs = []digest.Digest{
+ "sha256:e1d829eddb62dc49f1c56dbf8acd0c71299b3996115399de853a9d66d81b822f",
+ "sha256:02404b4d7e5d89b1383ca346b4462b199128aa4b238c5a2b2c186004ac148ba8",
+ "sha256:45fad80a4b1cec165c421eb570dec312d825bd8fac362e255028fa3f2169148d",
+ "sha256:7ddef8efd44586e54880ec4797458eac87b368544c438d7e7c63fbc0d9a7ae97",
+ "sha256:b56b16b6407ba1b86252e7e50f98f142cf6844fab42e4495d56ebb7ce559e2af",
+ "sha256:9bd63850e406167b4751f5050f6dc0ebd789bb5ef5e5c6c31ed062bda8c063e8",
+}
+
+var schema1WithThrowawaysFixtureLayerInfos = []types.BlobInfo{
+ {Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb", Size: 51354364},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c", Size: 150},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9", Size: 11739507},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909", Size: 8841833},
+ {Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa", Size: 291},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+ {Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))},
+}
+
+var schema1WithThrowawaysFixtureLayerDiffIDs = []digest.Digest{
+ "sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab",
+ GzippedEmptyLayerDigest,
+ GzippedEmptyLayerDigest,
+ GzippedEmptyLayerDigest,
+ "sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c",
+ GzippedEmptyLayerDigest,
+ "sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56",
+ GzippedEmptyLayerDigest,
+ GzippedEmptyLayerDigest,
+ GzippedEmptyLayerDigest,
+ GzippedEmptyLayerDigest,
+ "sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9",
+ "sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b",
+ GzippedEmptyLayerDigest,
+ GzippedEmptyLayerDigest,
+}
+
+func manifestSchema1FromFixture(t *testing.T, fixture string) genericManifest {
+ manifest, err := os.ReadFile(filepath.Join("fixtures", fixture))
+ require.NoError(t, err)
+
+ m, err := manifestSchema1FromManifest(manifest)
+ require.NoError(t, err)
+ return m
+}
+
+func manifestSchema1FromComponentsLikeFixture(t *testing.T) genericManifest {
+ ref, err := reference.ParseNormalizedNamed("rhosp12/openstack-nova-api:latest")
+ require.NoError(t, err)
+ m, err := manifestSchema1FromComponents(ref, []manifest.Schema1FSLayers{
+ {BlobSum: "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d"},
+ {BlobSum: "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788"},
+ {BlobSum: "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6"},
+ {BlobSum: "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e"},
+ {BlobSum: "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a"},
+ {BlobSum: "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4"},
+ }, []manifest.Schema1History{
+ {V1Compatibility: "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"9428cdea83ba\",\"Domainname\":\"\",\"User\":\"nova\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"container=oci\",\"KOLLA_BASE_DISTRO=rhel\",\"KOLLA_INSTALL_TYPE=binary\",\"KOLLA_INSTALL_METATYPE=rhos\",\"PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ \"],\"Cmd\":[\"kolla_start\"],\"Healthcheck\":{\"Test\":[\"CMD-SHELL\",\"/openstack/healthcheck\"]},\"ArgsEscaped\":true,\"Image\":\"3bf9afe371220b1eb1c57bec39b5a99ba976c36c92d964a1c014584f95f51e33\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"Kolla-SHA\":\"5.0.0-39-g6f1b947b\",\"architecture\":\"x86_64\",\"authoritative-source-url\":\"registry.access.redhat.com\",\"build-date\":\"2018-01-25T00:32:27.807261\",\"com.redhat.build-host\":\"ip-10-29-120-186.ec2.internal\",\"com.redhat.component\":\"openstack-nova-api-docker\",\"description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"distribution-scope\":\"public\",\"io.k8s.description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.k8s.display-name\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.openshift.tags\":\"rhosp osp openstack osp-12.0\",\"kolla_version\":\"stable/pike\",\"name\":\"rhosp12/openstack-nova-api\",\"release\":\"20180124.1\",\"summary\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"tripleo-common_version\":\"7.6.3-23-g4891cfe\",\"url\":\"https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1\",\"vcs-ref\":\"9b31243b7b448eb2fc3b6e2c96935b948f806e98\",\"vcs-type\":\"git\",\"vendor\":\"Red Hat, Inc.\",\"version\":\"12.0\",\"version-release\":\"12.0-20180124.1\"}},\"container_config\":{\"Hostname\":\"9428cdea83ba\",\"Domainname\":\"\",\"User\":\"nova\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"container=oci\",\"KOLLA_BASE_DISTRO=rhel\",\"KOLLA_INSTALL_TYPE=binary\",\"KOLLA_INSTALL_METATYPE=rhos\",\"PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ \"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"USER [nova]\"],\"Healthcheck\":{\"Test\":[\"CMD-SHELL\",\"/openstack/healthcheck\"]},\"ArgsEscaped\":true,\"Image\":\"sha256:274ce4dcbeb09fa173a5d50203ae5cec28f456d1b8b59477b47a42bd74d068bf\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"Kolla-SHA\":\"5.0.0-39-g6f1b947b\",\"architecture\":\"x86_64\",\"authoritative-source-url\":\"registry.access.redhat.com\",\"build-date\":\"2018-01-25T00:32:27.807261\",\"com.redhat.build-host\":\"ip-10-29-120-186.ec2.internal\",\"com.redhat.component\":\"openstack-nova-api-docker\",\"description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"distribution-scope\":\"public\",\"io.k8s.description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.k8s.display-name\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.openshift.tags\":\"rhosp osp openstack osp-12.0\",\"kolla_version\":\"stable/pike\",\"name\":\"rhosp12/openstack-nova-api\",\"release\":\"20180124.1\",\"summary\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"tripleo-common_version\":\"7.6.3-23-g4891cfe\",\"url\":\"https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1\",\"vcs-ref\":\"9b31243b7b448eb2fc3b6e2c96935b948f806e98\",\"vcs-type\":\"git\",\"vendor\":\"Red Hat, Inc.\",\"version\":\"12.0\",\"version-release\":\"12.0-20180124.1\"}},\"created\":\"2018-01-25T00:37:48.268558Z\",\"docker_version\":\"1.12.6\",\"id\":\"486cbbaf6c6f7d890f9368c86eda3f4ebe3ae982b75098037eb3c3cc6f0e0cdf\",\"os\":\"linux\",\"parent\":\"20d0c9c79f9fee83c4094993335b9b321112f13eef60ed9ec1599c7593dccf20\"}"},
+ {V1Compatibility: "{\"id\":\"20d0c9c79f9fee83c4094993335b9b321112f13eef60ed9ec1599c7593dccf20\",\"parent\":\"47a1014db2116c312736e11adcc236fb77d0ad32457f959cbaec0c3fc9ab1caa\",\"created\":\"2018-01-24T23:08:25.300741Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'\"]}}"},
+ {V1Compatibility: "{\"id\":\"47a1014db2116c312736e11adcc236fb77d0ad32457f959cbaec0c3fc9ab1caa\",\"parent\":\"cec66cab6c92a5f7b50ef407b80b83840a0d089b9896257609fd01de3a595824\",\"created\":\"2018-01-24T22:00:57.807862Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'\"]}}"},
+ {V1Compatibility: "{\"id\":\"cec66cab6c92a5f7b50ef407b80b83840a0d089b9896257609fd01de3a595824\",\"parent\":\"0e7730eccb3d014b33147b745d771bc0e38a967fd932133a6f5325a3c84282e2\",\"created\":\"2018-01-24T21:40:32.494686Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'\"]}}"},
+ {V1Compatibility: "{\"id\":\"0e7730eccb3d014b33147b745d771bc0e38a967fd932133a6f5325a3c84282e2\",\"parent\":\"3e49094c0233214ab73f8e5c204af8a14cfc6f0403384553c17fbac2e9d38345\",\"created\":\"2017-11-21T16:49:37.292899Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/compose-rpms-1.repo'\"]},\"author\":\"Red Hat, Inc.\"}"},
+ {V1Compatibility: "{\"id\":\"3e49094c0233214ab73f8e5c204af8a14cfc6f0403384553c17fbac2e9d38345\",\"comment\":\"Imported from -\",\"created\":\"2017-11-21T16:47:27.755341705Z\",\"container_config\":{\"Cmd\":[\"\"]}}"},
+ }, "amd64")
+ require.NoError(t, err)
+ return m
+}
+
+func TestManifestSchema1FromManifest(t *testing.T) {
+ // This just tests that the JSON can be loaded; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ _ = manifestSchema1FromFixture(t, "schema1.json")
+
+ // FIXME: Detailed coverage of manifest.Schema1FromManifest failures
+ _, err := manifestSchema1FromManifest([]byte{})
+ assert.Error(t, err)
+}
+
+func TestManifestSchema1FromComponents(t *testing.T) {
+ // This just smoke-tests that the manifest can be created; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ _ = manifestSchema1FromComponentsLikeFixture(t)
+
+ // Error on invalid input
+ _, err := manifestSchema1FromComponents(nil, []manifest.Schema1FSLayers{}, []manifest.Schema1History{}, "amd64")
+ assert.Error(t, err)
+}
+
+func TestManifestSchema1Serialize(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ serialized, err := m.serialize()
+ require.NoError(t, err)
+ // Drop "signatures" which is generated by AddDummyV2S1Signature
+ // We would ideally like to compare “serialized” with some transformation of
+ // the original fixture, but the ordering of fields in JSON maps is undefined, so this is
+ // easier.
+ assertJSONEqualsFixture(t, serialized, "schema1.json", "signatures")
+ }
+}
+
+func TestManifestSchema1ManifestMIMEType(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, m.manifestMIMEType())
+ }
+}
+
+func TestManifestSchema1ConfigInfo(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ assert.Equal(t, types.BlobInfo{Digest: ""}, m.ConfigInfo())
+ }
+}
+
+func TestManifestSchema1ConfigBlob(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ blob, err := m.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assert.Nil(t, blob)
+ }
+}
+
+func TestManifestSchema1OCIConfig(t *testing.T) {
+ m := manifestSchema1FromFixture(t, "schema1-for-oci-config.json")
+ configOCI, err := m.OCIConfig(context.Background())
+ require.NoError(t, err)
+ // FIXME: A more comprehensive test?
+ assert.Equal(t, "/pause", configOCI.Config.Entrypoint[0])
+}
+
+func TestManifestSchema1LayerInfo(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4",
+ Size: -1,
+ },
+ {
+ Digest: "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a",
+ Size: -1,
+ },
+ {
+ Digest: "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e",
+ Size: -1,
+ },
+ {
+ Digest: "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6",
+ Size: -1,
+ },
+ {
+ Digest: "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788",
+ Size: -1,
+ },
+ {
+ Digest: "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d",
+ Size: -1,
+ },
+ }, m.LayerInfos())
+ }
+}
+
+func TestManifestSchema1EmbeddedDockerReferenceConflicts(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ for name, expected := range map[string]bool{
+ "rhosp12/openstack-nova-api:latest": false, // Exactly the embedded reference
+ "example.com/rhosp12/openstack-nova-api:latest": false, // A different host name, but path and tag match
+ "docker.io:3333/rhosp12/openstack-nova-api:latest": false, // A different port, but path and tag match
+ "busybox": true, // Entirely different, minimal
+ "example.com:5555/ns/repo:tag": true, // Entirely different, maximal
+ "rhosp12/openstack-nova-api": true, // Missing tag
+ "rhosp12/openstack-nova-api:notlatest": true, // Different tag
+ "notrhosp12/openstack-nova-api:latest": true, // Different namespace
+ "rhosp12/notopenstack-nova-api:latest": true, // Different repo
+ } {
+ ref, err := reference.ParseNormalizedNamed(name)
+ require.NoError(t, err, name)
+ conflicts := m.EmbeddedDockerReferenceConflicts(ref)
+ assert.Equal(t, expected, conflicts, name)
+ }
+ }
+}
+
+func TestManifestSchema1Inspect(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ ii, err := m.Inspect(context.Background())
+ require.NoError(t, err)
+ created := time.Date(2018, 1, 25, 0, 37, 48, 268558000, time.UTC)
+ var emptyAnnotations map[string]string
+ assert.Equal(t, types.ImageInspectInfo{
+ Tag: "latest",
+ Created: &created,
+ DockerVersion: "1.12.6",
+ Labels: map[string]string{
+ "Kolla-SHA": "5.0.0-39-g6f1b947b",
+ "architecture": "x86_64",
+ "authoritative-source-url": "registry.access.redhat.com",
+ "build-date": "2018-01-25T00:32:27.807261",
+ "com.redhat.build-host": "ip-10-29-120-186.ec2.internal",
+ "com.redhat.component": "openstack-nova-api-docker",
+ "description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "distribution-scope": "public",
+ "io.k8s.description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.k8s.display-name": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.openshift.tags": "rhosp osp openstack osp-12.0",
+ "kolla_version": "stable/pike",
+ "name": "rhosp12/openstack-nova-api",
+ "release": "20180124.1",
+ "summary": "Red Hat OpenStack Platform 12.0 nova-api",
+ "tripleo-common_version": "7.6.3-23-g4891cfe",
+ "url": "https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1",
+ "vcs-ref": "9b31243b7b448eb2fc3b6e2c96935b948f806e98",
+ "vcs-type": "git",
+ "vendor": "Red Hat, Inc.",
+ "version": "12.0",
+ "version-release": "12.0-20180124.1",
+ },
+ Architecture: "amd64",
+ Os: "linux",
+ Layers: []string{
+ "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4",
+ "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a",
+ "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e",
+ "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6",
+ "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788",
+ "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d",
+ },
+ LayersData: []types.ImageInspectLayer{{
+ MIMEType: "",
+ Digest: "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4",
+ Size: -1,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "",
+ Digest: "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a",
+ Size: -1,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "",
+ Digest: "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e",
+ Size: -1,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "",
+ Digest: "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6",
+ Size: -1,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "",
+ Digest: "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788",
+ Size: -1,
+ Annotations: emptyAnnotations,
+ },
+ {
+ MIMEType: "",
+ Digest: "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d",
+ Size: -1,
+ Annotations: emptyAnnotations,
+ },
+ },
+ Author: "",
+ Env: []string{
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "container=oci",
+ "KOLLA_BASE_DISTRO=rhel",
+ "KOLLA_INSTALL_TYPE=binary",
+ "KOLLA_INSTALL_METATYPE=rhos",
+ "PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ ",
+ },
+ }, *ii)
+ }
+}
+
+func TestManifestSchema1UpdatedImageNeedsLayerDiffIDs(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ for mt, expected := range map[string]bool{
+ "": false,
+ manifest.DockerV2Schema1MediaType: false,
+ manifest.DockerV2Schema1SignedMediaType: false,
+ manifest.DockerV2Schema2MediaType: true,
+ imgspecv1.MediaTypeImageManifest: true,
+ } {
+ needsDiffIDs := m.UpdatedImageNeedsLayerDiffIDs(types.ManifestUpdateOptions{
+ ManifestMIMEType: mt,
+ })
+ assert.Equal(t, expected, needsDiffIDs, mt)
+ }
+ }
+}
+
+func TestManifestSchema1UpdatedImage(t *testing.T) {
+ original := manifestSchema1FromFixture(t, "schema1.json")
+
+ // LayerInfos:
+ layerInfos := append(slices.Clone(original.LayerInfos()[1:]), original.LayerInfos()[0])
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfos,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, layerInfos, res.LayerInfos())
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: append(layerInfos, layerInfos[0]),
+ })
+ assert.Error(t, err)
+
+ // EmbeddedDockerReference:
+ for _, refName := range []string{
+ "busybox",
+ "busybox:notlatest",
+ "rhosp12/openstack-nova-api:latest",
+ } {
+ embeddedRef, err := reference.ParseNormalizedNamed(refName)
+ require.NoError(t, err)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ EmbeddedDockerReference: embeddedRef,
+ })
+ require.NoError(t, err)
+ // The previous embedded docker reference now does not match.
+ nonEmbeddedRef, err := reference.ParseNormalizedNamed("rhosp12/openstack-nova-api:latest")
+ require.NoError(t, err)
+ conflicts := res.EmbeddedDockerReferenceConflicts(nonEmbeddedRef)
+ assert.Equal(t, refName != "rhosp12/openstack-nova-api:latest", conflicts)
+ }
+
+ // ManifestMIMEType:
+ // Only smoke-test the valid conversions, detailed tests are below. (This also verifies that “original” is not affected.)
+ for _, mime := range []string{
+ manifest.DockerV2Schema2MediaType,
+ imgspecv1.MediaTypeImageManifest,
+ } {
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: mime,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: schema1FixtureLayerInfos,
+ LayerDiffIDs: schema1FixtureLayerDiffIDs,
+ },
+ })
+ assert.NoError(t, err, mime)
+ }
+ for _, mime := range []string{
+ "this is invalid",
+ } {
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: mime,
+ })
+ assert.Error(t, err, mime)
+ }
+
+ // m hasn’t been changed:
+ m2 := manifestSchema1FromFixture(t, "schema1.json")
+ typedOriginal, ok := original.(*manifestSchema1)
+ require.True(t, ok)
+ typedM2, ok := m2.(*manifestSchema1)
+ require.True(t, ok)
+ assert.Equal(t, *typedM2, *typedOriginal)
+}
+
+func TestManifestSchema1ConvertToSchema2(t *testing.T) {
+ original := manifestSchema1FromFixture(t, "schema1.json")
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: schema1FixtureLayerInfos,
+ LayerDiffIDs: schema1FixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, mt)
+ // Ignore "config": we don’t want to hard-code a specific digest and size of the marshaled config here.
+ assertJSONEqualsFixture(t, convertedJSON, "schema1-to-schema2.json", "config")
+
+ convertedConfig, err := res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "schema1-to-schema2-config.json")
+
+ // Conversion to schema2 together with changing LayerInfos works as expected (which requires
+ // handling schema1 throwaway layers):
+ // Use the recorded result of converting the schema2 fixture to schema1, because that one
+ // (unlike schem1.json) contains throwaway layers.
+ original = manifestSchema1FromFixture(t, "schema2-to-schema1-by-docker.json")
+ updatedLayers, updatedLayersCopy := modifiedLayerInfos(t, schema1WithThrowawaysFixtureLayerInfos)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: updatedLayers,
+ LayerDiffIDs: schema1WithThrowawaysFixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, mt)
+ // Layers have been updated as expected
+ originalSrc := newSchema2ImageSource(t, "httpd:latest")
+ s2Manifest, err := manifestSchema2FromManifest(originalSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5ba",
+ Size: 51354365,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680d",
+ Size: 151,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a8",
+ Size: 11739506,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25908",
+ Size: 8841832,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fb",
+ Size: 290,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ }, s2Manifest.LayerInfos())
+
+ // Conversion to schema2 with encryption fails
+ encryptedLayers := layerInfosWithCryptoOperation(original.LayerInfos(), types.Encrypt)
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: encryptedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: updatedLayers,
+ LayerDiffIDs: schema1WithThrowawaysFixtureLayerDiffIDs,
+ },
+ })
+ assert.Error(t, err)
+
+ // FIXME? Test also the various failure cases, if only to see that we don't crash?
+}
+
+func TestManifestSchema1ConvertToManifestOCI1(t *testing.T) {
+ original := manifestSchema1FromFixture(t, "schema1.json")
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: schema1FixtureLayerInfos,
+ LayerDiffIDs: schema1FixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, mt)
+ // Ignore "config": we don’t want to hard-code a specific digest and size of the marshaled config here.
+ assertJSONEqualsFixture(t, convertedJSON, "schema1-to-oci1.json", "config")
+
+ convertedConfig, err := res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "schema1-to-oci1-config.json")
+
+ // Conversion to OCI together with changing LayerInfos works as expected (which requires
+ // handling schema1 throwaway layers):
+ // Use the recorded result of converting the schema2 fixture to schema1, because that one
+ // (unlike schem1.json) contains throwaway layers.
+ original = manifestSchema1FromFixture(t, "schema2-to-schema1-by-docker.json")
+ updatedLayers, updatedLayersCopy := modifiedLayerInfos(t, schema1WithThrowawaysFixtureLayerInfos)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ InformationOnly: types.ManifestUpdateInformation{ // FIXME: deduplicate this data
+ LayerInfos: updatedLayers,
+ LayerDiffIDs: schema1WithThrowawaysFixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, mt)
+ // Layers have been updated as expected
+ originalSrc := newSchema2ImageSource(t, "httpd:latest")
+ ociManifest, err := manifestOCI1FromManifest(originalSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5ba",
+ Size: 51354365,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680d",
+ Size: 151,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a8",
+ Size: 11739506,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25908",
+ Size: 8841832,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fb",
+ Size: 290,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ },
+ }, ociManifest.LayerInfos())
+
+ // Conversion to OCI with encryption is possible.
+ encryptedLayers := layerInfosWithCryptoOperation(schema1WithThrowawaysFixtureLayerInfos, types.Encrypt)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: encryptedLayers,
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: encryptedLayers,
+ LayerDiffIDs: schema1WithThrowawaysFixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, mt)
+ // Layers have been updated as expected
+ ociManifest, err = manifestOCI1FromManifest(originalSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ }, ociManifest.LayerInfos())
+
+ // FIXME? Test also the various failure cases, if only to see that we don't crash?
+}
+
+func TestConvertSchema1ToManifestOCIWithAnnotations(t *testing.T) {
+ // Test when converting an image from schema 1 (which doesn't support certain fields like
+ // URLs, annotations, etc.) to an OCI image (which supports those fields),
+ // that UpdatedImage propagates the features to the converted manifest.
+
+ original := manifestSchema1FromFixture(t, "schema1.json")
+ layerInfoOverwrites := []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ URLs: []string{
+ "https://layer.url",
+ },
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ Annotations: map[string]string{
+ "test-annotation-2": "two",
+ },
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ }
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ LayerInfos: layerInfoOverwrites,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: schema1FixtureLayerInfos,
+ LayerDiffIDs: schema1FixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, res.LayerInfos(), layerInfoOverwrites)
+
+ // Doing this with schema2 should fail
+ original = manifestSchema1FromFixture(t, "schema1.json")
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ LayerInfos: layerInfoOverwrites,
+ InformationOnly: types.ManifestUpdateInformation{
+ LayerInfos: schema1FixtureLayerInfos,
+ LayerDiffIDs: schema1FixtureLayerDiffIDs,
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEqual(t, res.LayerInfos(), layerInfoOverwrites)
+}
+
+func TestManifestSchema1CanChangeLayerCompression(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema1FromFixture(t, "schema1.json"),
+ manifestSchema1FromComponentsLikeFixture(t),
+ } {
+ assert.True(t, m.CanChangeLayerCompression(""))
+ }
+}
diff --git a/internal/image/docker_schema2.go b/internal/image/docker_schema2.go
new file mode 100644
index 0000000..c3234c3
--- /dev/null
+++ b/internal/image/docker_schema2.go
@@ -0,0 +1,413 @@
+package image
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/iolimits"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/pkg/blobinfocache/none"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/sirupsen/logrus"
+)
+
+// GzippedEmptyLayer is a gzip-compressed version of an empty tar file (1024 NULL bytes)
+// This comes from github.com/docker/distribution/manifest/schema1/config_builder.go; there is
+// a non-zero embedded timestamp; we could zero that, but that would just waste storage space
+// in registries, so let’s use the same values.
+//
+// This is publicly visible as c/image/image.GzippedEmptyLayer.
+var GzippedEmptyLayer = []byte{
+ 31, 139, 8, 0, 0, 9, 110, 136, 0, 255, 98, 24, 5, 163, 96, 20, 140, 88,
+ 0, 8, 0, 0, 255, 255, 46, 175, 181, 239, 0, 4, 0, 0,
+}
+
+// GzippedEmptyLayerDigest is a digest of GzippedEmptyLayer
+//
+// This is publicly visible as c/image/image.GzippedEmptyLayerDigest.
+const GzippedEmptyLayerDigest = digest.Digest("sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
+
+type manifestSchema2 struct {
+ src types.ImageSource // May be nil if configBlob is not nil
+ configBlob []byte // If set, corresponds to contents of ConfigDescriptor.
+ m *manifest.Schema2
+}
+
+func manifestSchema2FromManifest(src types.ImageSource, manifestBlob []byte) (genericManifest, error) {
+ m, err := manifest.Schema2FromManifest(manifestBlob)
+ if err != nil {
+ return nil, err
+ }
+ return &manifestSchema2{
+ src: src,
+ m: m,
+ }, nil
+}
+
+// manifestSchema2FromComponents builds a new manifestSchema2 from the supplied data:
+func manifestSchema2FromComponents(config manifest.Schema2Descriptor, src types.ImageSource, configBlob []byte, layers []manifest.Schema2Descriptor) *manifestSchema2 {
+ return &manifestSchema2{
+ src: src,
+ configBlob: configBlob,
+ m: manifest.Schema2FromComponents(config, layers),
+ }
+}
+
+func (m *manifestSchema2) serialize() ([]byte, error) {
+ return m.m.Serialize()
+}
+
+func (m *manifestSchema2) manifestMIMEType() string {
+ return m.m.MediaType
+}
+
+// ConfigInfo returns a complete BlobInfo for the separate config object, or a BlobInfo{Digest:""} if there isn't a separate object.
+// Note that the config object may not exist in the underlying storage in the return value of UpdatedImage! Use ConfigBlob() below.
+func (m *manifestSchema2) ConfigInfo() types.BlobInfo {
+ return m.m.ConfigInfo()
+}
+
+// OCIConfig returns the image configuration as per OCI v1 image-spec. Information about
+// layers in the resulting configuration isn't guaranteed to be returned to due how
+// old image manifests work (docker v2s1 especially).
+func (m *manifestSchema2) OCIConfig(ctx context.Context) (*imgspecv1.Image, error) {
+ configBlob, err := m.ConfigBlob(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // docker v2s2 and OCI v1 are mostly compatible but v2s2 contains more fields
+ // than OCI v1. This unmarshal makes sure we drop docker v2s2
+ // fields that aren't needed in OCI v1.
+ configOCI := &imgspecv1.Image{}
+ if err := json.Unmarshal(configBlob, configOCI); err != nil {
+ return nil, err
+ }
+ return configOCI, nil
+}
+
+// ConfigBlob returns the blob described by ConfigInfo, iff ConfigInfo().Digest != ""; nil otherwise.
+// The result is cached; it is OK to call this however often you need.
+func (m *manifestSchema2) ConfigBlob(ctx context.Context) ([]byte, error) {
+ if m.configBlob == nil {
+ if m.src == nil {
+ return nil, fmt.Errorf("Internal error: neither src nor configBlob set in manifestSchema2")
+ }
+ stream, _, err := m.src.GetBlob(ctx, manifest.BlobInfoFromSchema2Descriptor(m.m.ConfigDescriptor), none.NoCache)
+ if err != nil {
+ return nil, err
+ }
+ defer stream.Close()
+ blob, err := iolimits.ReadAtMost(stream, iolimits.MaxConfigBodySize)
+ if err != nil {
+ return nil, err
+ }
+ computedDigest := digest.FromBytes(blob)
+ if computedDigest != m.m.ConfigDescriptor.Digest {
+ return nil, fmt.Errorf("Download config.json digest %s does not match expected %s", computedDigest, m.m.ConfigDescriptor.Digest)
+ }
+ m.configBlob = blob
+ }
+ return m.configBlob, nil
+}
+
+// LayerInfos returns a list of BlobInfos of layers referenced by this image, in order (the root layer first, and then successive layered layers).
+// The Digest field is guaranteed to be provided; Size may be -1.
+// WARNING: The list may contain duplicates, and they are semantically relevant.
+func (m *manifestSchema2) LayerInfos() []types.BlobInfo {
+ return manifestLayerInfosToBlobInfos(m.m.LayerInfos())
+}
+
+// EmbeddedDockerReferenceConflicts whether a Docker reference embedded in the manifest, if any, conflicts with destination ref.
+// It returns false if the manifest does not embed a Docker reference.
+// (This embedding unfortunately happens for Docker schema1, please do not add support for this in any new formats.)
+func (m *manifestSchema2) EmbeddedDockerReferenceConflicts(ref reference.Named) bool {
+ return false
+}
+
+// Inspect returns various information for (skopeo inspect) parsed from the manifest and configuration.
+func (m *manifestSchema2) Inspect(ctx context.Context) (*types.ImageInspectInfo, error) {
+ getter := func(info types.BlobInfo) ([]byte, error) {
+ if info.Digest != m.ConfigInfo().Digest {
+ // Shouldn't ever happen
+ return nil, errors.New("asked for a different config blob")
+ }
+ config, err := m.ConfigBlob(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return config, nil
+ }
+ return m.m.Inspect(getter)
+}
+
+// UpdatedImageNeedsLayerDiffIDs returns true iff UpdatedImage(options) needs InformationOnly.LayerDiffIDs.
+// This is a horribly specific interface, but computing InformationOnly.LayerDiffIDs can be very expensive to compute
+// (most importantly it forces us to download the full layers even if they are already present at the destination).
+func (m *manifestSchema2) UpdatedImageNeedsLayerDiffIDs(options types.ManifestUpdateOptions) bool {
+ return false
+}
+
+// UpdatedImage returns a types.Image modified according to options.
+// This does not change the state of the original Image object.
+// The returned error will be a manifest.ManifestLayerCompressionIncompatibilityError
+// if the CompressionOperation and CompressionAlgorithm specified in one or more
+// options.LayerInfos items is anything other than gzip.
+func (m *manifestSchema2) UpdatedImage(ctx context.Context, options types.ManifestUpdateOptions) (types.Image, error) {
+ copy := manifestSchema2{ // NOTE: This is not a deep copy, it still shares slices etc.
+ src: m.src,
+ configBlob: m.configBlob,
+ m: manifest.Schema2Clone(m.m),
+ }
+
+ converted, err := convertManifestIfRequiredWithUpdate(ctx, options, map[string]manifestConvertFn{
+ manifest.DockerV2Schema1MediaType: copy.convertToManifestSchema1,
+ manifest.DockerV2Schema1SignedMediaType: copy.convertToManifestSchema1,
+ imgspecv1.MediaTypeImageManifest: copy.convertToManifestOCI1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if converted != nil {
+ return converted, nil
+ }
+
+ // No conversion required, update manifest
+ if options.LayerInfos != nil {
+ if err := copy.m.UpdateLayerInfos(options.LayerInfos); err != nil {
+ return nil, err
+ }
+ }
+ // Ignore options.EmbeddedDockerReference: it may be set when converting from schema1 to schema2, but we really don't care.
+
+ return memoryImageFromManifest(&copy), nil
+}
+
+func oci1DescriptorFromSchema2Descriptor(d manifest.Schema2Descriptor) imgspecv1.Descriptor {
+ return imgspecv1.Descriptor{
+ MediaType: d.MediaType,
+ Size: d.Size,
+ Digest: d.Digest,
+ URLs: d.URLs,
+ }
+}
+
+// convertToManifestOCI1 returns a genericManifest implementation converted to imgspecv1.MediaTypeImageManifest.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestSchema2 object.
+func (m *manifestSchema2) convertToManifestOCI1(ctx context.Context, _ *types.ManifestUpdateOptions) (genericManifest, error) {
+ configOCI, err := m.OCIConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ configOCIBytes, err := json.Marshal(configOCI)
+ if err != nil {
+ return nil, err
+ }
+
+ config := imgspecv1.Descriptor{
+ MediaType: imgspecv1.MediaTypeImageConfig,
+ Size: int64(len(configOCIBytes)),
+ Digest: digest.FromBytes(configOCIBytes),
+ }
+
+ layers := make([]imgspecv1.Descriptor, len(m.m.LayersDescriptors))
+ for idx := range layers {
+ layers[idx] = oci1DescriptorFromSchema2Descriptor(m.m.LayersDescriptors[idx])
+ switch m.m.LayersDescriptors[idx].MediaType {
+ case manifest.DockerV2Schema2ForeignLayerMediaType:
+ layers[idx].MediaType = imgspecv1.MediaTypeImageLayerNonDistributable //nolint:staticcheck // NonDistributable layers are deprecated, but we want to continue to support manipulating pre-existing images.
+ case manifest.DockerV2Schema2ForeignLayerMediaTypeGzip:
+ layers[idx].MediaType = imgspecv1.MediaTypeImageLayerNonDistributableGzip //nolint:staticcheck // NonDistributable layers are deprecated, but we want to continue to support manipulating pre-existing images.
+ case manifest.DockerV2SchemaLayerMediaTypeUncompressed:
+ layers[idx].MediaType = imgspecv1.MediaTypeImageLayer
+ case manifest.DockerV2Schema2LayerMediaType:
+ layers[idx].MediaType = imgspecv1.MediaTypeImageLayerGzip
+ default:
+ return nil, fmt.Errorf("Unknown media type during manifest conversion: %q", m.m.LayersDescriptors[idx].MediaType)
+ }
+ }
+
+ return manifestOCI1FromComponents(config, m.src, configOCIBytes, layers), nil
+}
+
+// convertToManifestSchema1 returns a genericManifest implementation converted to manifest.DockerV2Schema1{Signed,}MediaType.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestSchema2 object.
+//
+// Based on docker/distribution/manifest/schema1/config_builder.go
+func (m *manifestSchema2) convertToManifestSchema1(ctx context.Context, options *types.ManifestUpdateOptions) (genericManifest, error) {
+ dest := options.InformationOnly.Destination
+
+ var convertedLayerUpdates []types.BlobInfo // Only used if options.LayerInfos != nil
+ if options.LayerInfos != nil {
+ if len(options.LayerInfos) != len(m.m.LayersDescriptors) {
+ return nil, fmt.Errorf("Error converting image: layer edits for %d layers vs %d existing layers",
+ len(options.LayerInfos), len(m.m.LayersDescriptors))
+ }
+ convertedLayerUpdates = []types.BlobInfo{}
+ }
+
+ configBytes, err := m.ConfigBlob(ctx)
+ if err != nil {
+ return nil, err
+ }
+ imageConfig := &manifest.Schema2Image{}
+ if err := json.Unmarshal(configBytes, imageConfig); err != nil {
+ return nil, err
+ }
+
+ // Build fsLayers and History, discarding all configs. We will patch the top-level config in later.
+ fsLayers := make([]manifest.Schema1FSLayers, len(imageConfig.History))
+ history := make([]manifest.Schema1History, len(imageConfig.History))
+ nonemptyLayerIndex := 0
+ var parentV1ID string // Set in the loop
+ v1ID := ""
+ haveGzippedEmptyLayer := false
+ if len(imageConfig.History) == 0 {
+ // What would this even mean?! Anyhow, the rest of the code depends on fsLayers[0] and history[0] existing.
+ return nil, fmt.Errorf("Cannot convert an image with 0 history entries to %s", manifest.DockerV2Schema1SignedMediaType)
+ }
+ for v2Index, historyEntry := range imageConfig.History {
+ parentV1ID = v1ID
+ v1Index := len(imageConfig.History) - 1 - v2Index
+
+ var blobDigest digest.Digest
+ if historyEntry.EmptyLayer {
+ emptyLayerBlobInfo := types.BlobInfo{Digest: GzippedEmptyLayerDigest, Size: int64(len(GzippedEmptyLayer))}
+
+ if !haveGzippedEmptyLayer {
+ logrus.Debugf("Uploading empty layer during conversion to schema 1")
+ // Ideally we should update the relevant BlobInfoCache about this layer, but that would require passing it down here,
+ // and anyway this blob is so small that it’s easier to just copy it than to worry about figuring out another location where to get it.
+ info, err := dest.PutBlob(ctx, bytes.NewReader(GzippedEmptyLayer), emptyLayerBlobInfo, none.NoCache, false)
+ if err != nil {
+ return nil, fmt.Errorf("uploading empty layer: %w", err)
+ }
+ if info.Digest != emptyLayerBlobInfo.Digest {
+ return nil, fmt.Errorf("Internal error: Uploaded empty layer has digest %#v instead of %s", info.Digest, emptyLayerBlobInfo.Digest)
+ }
+ haveGzippedEmptyLayer = true
+ }
+ if options.LayerInfos != nil {
+ convertedLayerUpdates = append(convertedLayerUpdates, emptyLayerBlobInfo)
+ }
+ blobDigest = emptyLayerBlobInfo.Digest
+ } else {
+ if nonemptyLayerIndex >= len(m.m.LayersDescriptors) {
+ return nil, fmt.Errorf("Invalid image configuration, needs more than the %d distributed layers", len(m.m.LayersDescriptors))
+ }
+ if options.LayerInfos != nil {
+ convertedLayerUpdates = append(convertedLayerUpdates, options.LayerInfos[nonemptyLayerIndex])
+ }
+ blobDigest = m.m.LayersDescriptors[nonemptyLayerIndex].Digest
+ nonemptyLayerIndex++
+ }
+
+ // AFAICT pull ignores these ID values, at least nowadays, so we could use anything unique, including a simple counter. Use what Docker uses for cargo-cult consistency.
+ v, err := v1IDFromBlobDigestAndComponents(blobDigest, parentV1ID)
+ if err != nil {
+ return nil, err
+ }
+ v1ID = v
+
+ fakeImage := manifest.Schema1V1Compatibility{
+ ID: v1ID,
+ Parent: parentV1ID,
+ Comment: historyEntry.Comment,
+ Created: historyEntry.Created,
+ Author: historyEntry.Author,
+ ThrowAway: historyEntry.EmptyLayer,
+ }
+ fakeImage.ContainerConfig.Cmd = []string{historyEntry.CreatedBy}
+ v1CompatibilityBytes, err := json.Marshal(&fakeImage)
+ if err != nil {
+ return nil, fmt.Errorf("Internal error: Error creating v1compatibility for %#v", fakeImage)
+ }
+
+ fsLayers[v1Index] = manifest.Schema1FSLayers{BlobSum: blobDigest}
+ history[v1Index] = manifest.Schema1History{V1Compatibility: string(v1CompatibilityBytes)}
+ // Note that parentV1ID of the top layer is preserved when exiting this loop
+ }
+
+ // Now patch in real configuration for the top layer (v1Index == 0)
+ v1ID, err = v1IDFromBlobDigestAndComponents(fsLayers[0].BlobSum, parentV1ID, string(configBytes)) // See above WRT v1ID value generation and cargo-cult consistency.
+ if err != nil {
+ return nil, err
+ }
+ v1Config, err := v1ConfigFromConfigJSON(configBytes, v1ID, parentV1ID, imageConfig.History[len(imageConfig.History)-1].EmptyLayer)
+ if err != nil {
+ return nil, err
+ }
+ history[0].V1Compatibility = string(v1Config)
+
+ if options.LayerInfos != nil {
+ options.LayerInfos = convertedLayerUpdates
+ }
+ m1, err := manifestSchema1FromComponents(dest.Reference().DockerReference(), fsLayers, history, imageConfig.Architecture)
+ if err != nil {
+ return nil, err // This should never happen, we should have created all the components correctly.
+ }
+ return m1, nil
+}
+
+func v1IDFromBlobDigestAndComponents(blobDigest digest.Digest, others ...string) (string, error) {
+ if err := blobDigest.Validate(); err != nil {
+ return "", err
+ }
+ parts := append([]string{blobDigest.Hex()}, others...)
+ v1IDHash := sha256.Sum256([]byte(strings.Join(parts, " ")))
+ return hex.EncodeToString(v1IDHash[:]), nil
+}
+
+func v1ConfigFromConfigJSON(configJSON []byte, v1ID, parentV1ID string, throwaway bool) ([]byte, error) {
+ // Preserve everything we don't specifically know about.
+ // (This must be a *json.RawMessage, even though *[]byte is fairly redundant, because only *RawMessage implements json.Marshaler.)
+ rawContents := map[string]*json.RawMessage{}
+ if err := json.Unmarshal(configJSON, &rawContents); err != nil { // We have already unmarshaled it before, using a more detailed schema?!
+ return nil, err
+ }
+ delete(rawContents, "rootfs")
+ delete(rawContents, "history")
+
+ updates := map[string]any{"id": v1ID}
+ if parentV1ID != "" {
+ updates["parent"] = parentV1ID
+ }
+ if throwaway {
+ updates["throwaway"] = throwaway
+ }
+ for field, value := range updates {
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+ rawContents[field] = (*json.RawMessage)(&encoded)
+ }
+ return json.Marshal(rawContents)
+}
+
+// SupportsEncryption returns if encryption is supported for the manifest type
+func (m *manifestSchema2) SupportsEncryption(context.Context) bool {
+ return false
+}
+
+// CanChangeLayerCompression returns true if we can compress/decompress layers with mimeType in the current image
+// (and the code can handle that).
+// NOTE: Even if this returns true, the relevant format might not accept all compression algorithms; the set of accepted
+// algorithms depends not on the current format, but possibly on the target of a conversion (if UpdatedImage converts
+// to a different manifest format).
+func (m *manifestSchema2) CanChangeLayerCompression(mimeType string) bool {
+ return m.m.CanChangeLayerCompression(mimeType)
+}
diff --git a/internal/image/docker_schema2_test.go b/internal/image/docker_schema2_test.go
new file mode 100644
index 0000000..cf3b7f4
--- /dev/null
+++ b/internal/image/docker_schema2_test.go
@@ -0,0 +1,726 @@
+package image
+
+import (
+ "bytes"
+ "context"
+ "encoding/hex"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/testing/mocks"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/slices"
+)
+
+const commonFixtureConfigDigest = "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f"
+
+func manifestSchema2FromFixture(t *testing.T, src types.ImageSource, fixture string, mustFail bool) genericManifest {
+ manifest, err := os.ReadFile(filepath.Join("fixtures", fixture))
+ require.NoError(t, err)
+
+ m, err := manifestSchema2FromManifest(src, manifest)
+ if mustFail {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ return m
+}
+
+func manifestSchema2FromComponentsLikeFixture(configBlob []byte) genericManifest {
+ return manifestSchema2FromComponents(manifest.Schema2Descriptor{
+ MediaType: "application/octet-stream",
+ Size: 5940,
+ Digest: commonFixtureConfigDigest,
+ }, nil, configBlob, []manifest.Schema2Descriptor{
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ },
+ })
+}
+
+func TestManifestSchema2FromManifest(t *testing.T) {
+ // This just tests that the JSON can be loaded; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ _ = manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false)
+
+ _, err := manifestSchema2FromManifest(nil, []byte{})
+ assert.Error(t, err)
+}
+
+func TestManifestSchema2FromComponents(t *testing.T) {
+ // This just smoke-tests that the manifest can be created; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ _ = manifestSchema2FromComponentsLikeFixture(nil)
+}
+
+func TestManifestSchema2Serialize(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ serialized, err := m.serialize()
+ require.NoError(t, err)
+ // We would ideally like to compare “serialized” with some transformation of
+ // the original fixture, but the ordering of fields in JSON maps is undefined, so this is
+ // easier.
+ assertJSONEqualsFixture(t, serialized, "schema2.json")
+ }
+}
+
+func TestManifestSchema2ManifestMIMEType(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, m.manifestMIMEType())
+ }
+}
+
+func TestManifestSchema2ConfigInfo(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ assert.Equal(t, types.BlobInfo{
+ Size: 5940,
+ Digest: commonFixtureConfigDigest,
+ MediaType: "application/octet-stream",
+ }, m.ConfigInfo())
+ }
+}
+
+// configBlobImageSource allows testing various GetBlob behaviors in .ConfigBlob()
+type configBlobImageSource struct {
+ mocks.ForbiddenImageSource // We inherit almost all of the methods, which just panic()
+ expectedDigest digest.Digest
+ f func() (io.ReadCloser, int64, error)
+}
+
+func (f configBlobImageSource) GetBlob(ctx context.Context, info types.BlobInfo, _ types.BlobInfoCache) (io.ReadCloser, int64, error) {
+ if info.Digest != f.expectedDigest {
+ panic("Unexpected digest in GetBlob")
+ }
+ return f.f()
+}
+
+func TestManifestSchema2ConfigBlob(t *testing.T) {
+ realConfigJSON, err := os.ReadFile("fixtures/schema2-config.json")
+ require.NoError(t, err)
+
+ for _, c := range []struct {
+ cbISfn func() (io.ReadCloser, int64, error)
+ blob []byte
+ }{
+ // Success
+ {func() (io.ReadCloser, int64, error) {
+ return io.NopCloser(bytes.NewReader(realConfigJSON)), int64(len(realConfigJSON)), nil
+ }, realConfigJSON},
+ // Various kinds of failures
+ {nil, nil},
+ {func() (io.ReadCloser, int64, error) {
+ return nil, -1, errors.New("Error returned from GetBlob")
+ }, nil},
+ {func() (io.ReadCloser, int64, error) {
+ reader, writer := io.Pipe()
+ err = writer.CloseWithError(errors.New("Expected error reading input in ConfigBlob"))
+ assert.NoError(t, err)
+ return reader, 1, nil
+ }, nil},
+ {func() (io.ReadCloser, int64, error) {
+ nonmatchingJSON := []byte("This does not match ConfigDescriptor.Digest")
+ return io.NopCloser(bytes.NewReader(nonmatchingJSON)), int64(len(nonmatchingJSON)), nil
+ }, nil},
+ } {
+ var src types.ImageSource
+ if c.cbISfn != nil {
+ src = configBlobImageSource{
+ expectedDigest: commonFixtureConfigDigest,
+ f: c.cbISfn,
+ }
+ } else {
+ src = nil
+ }
+ m := manifestSchema2FromFixture(t, src, "schema2.json", false)
+ blob, err := m.ConfigBlob(context.Background())
+ if c.blob != nil {
+ assert.NoError(t, err)
+ assert.Equal(t, c.blob, blob)
+ } else {
+ assert.Error(t, err)
+ }
+ }
+
+ // Generally configBlob should match ConfigInfo; we don’t quite need it to, and this will
+ // guarantee that the returned object is returning the original contents instead
+ // of reading an object from elsewhere.
+ configBlob := []byte("config blob which does not match ConfigInfo")
+ // This just tests that the manifest can be created; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ m := manifestSchema2FromComponentsLikeFixture(configBlob)
+ cb, err := m.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, configBlob, cb)
+}
+
+func TestManifestSchema2LayerInfo(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ }, m.LayerInfos())
+ }
+}
+
+func TestManifestSchema2EmbeddedDockerReferenceConflicts(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ for _, name := range []string{"busybox", "example.com:5555/ns/repo:tag"} {
+ ref, err := reference.ParseNormalizedNamed(name)
+ require.NoError(t, err)
+ conflicts := m.EmbeddedDockerReferenceConflicts(ref)
+ assert.False(t, conflicts)
+ }
+ }
+}
+
+func TestManifestSchema2Inspect(t *testing.T) {
+ configJSON, err := os.ReadFile("fixtures/schema2-config.json")
+ require.NoError(t, err)
+
+ m := manifestSchema2FromComponentsLikeFixture(configJSON)
+ ii, err := m.Inspect(context.Background())
+ require.NoError(t, err)
+ created := time.Date(2016, 9, 23, 23, 20, 45, 789764590, time.UTC)
+
+ var emptyAnnotations map[string]string
+ assert.Equal(t, types.ImageInspectInfo{
+ Tag: "",
+ Created: &created,
+ DockerVersion: "1.12.1",
+ Labels: map[string]string{},
+ Architecture: "amd64",
+ Os: "linux",
+ Layers: []string{
+ "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ },
+ LayersData: []types.ImageInspectLayer{{
+ MIMEType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ Annotations: emptyAnnotations,
+ },
+ },
+ Author: "",
+ Env: []string{
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download&filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc",
+ },
+ }, *ii)
+
+ // nil configBlob will trigger an error in m.ConfigBlob()
+ m = manifestSchema2FromComponentsLikeFixture(nil)
+ _, err = m.Inspect(context.Background())
+ assert.Error(t, err)
+
+ m = manifestSchema2FromComponentsLikeFixture([]byte("invalid JSON"))
+ _, err = m.Inspect(context.Background())
+ assert.Error(t, err)
+}
+
+func TestManifestSchema2UpdatedImageNeedsLayerDiffIDs(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ assert.False(t, m.UpdatedImageNeedsLayerDiffIDs(types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ }))
+ }
+}
+
+// schema2ImageSource is plausible enough for schema conversions in manifestSchema2.UpdatedImage() to work.
+type schema2ImageSource struct {
+ configBlobImageSource
+ ref reference.Named
+}
+
+func (s2is *schema2ImageSource) Reference() types.ImageReference {
+ return refImageReferenceMock{ref: s2is.ref}
+}
+
+// refImageReferenceMock is a mock of types.ImageReference which returns itself in DockerReference.
+type refImageReferenceMock struct {
+ mocks.ForbiddenImageReference // We inherit almost all of the methods, which just panic()
+ ref reference.Named
+}
+
+func (ref refImageReferenceMock) DockerReference() reference.Named {
+ return ref.ref
+}
+
+func newSchema2ImageSource(t *testing.T, dockerRef string) *schema2ImageSource {
+ realConfigJSON, err := os.ReadFile("fixtures/schema2-config.json")
+ require.NoError(t, err)
+
+ ref, err := reference.ParseNormalizedNamed(dockerRef)
+ require.NoError(t, err)
+
+ return &schema2ImageSource{
+ configBlobImageSource: configBlobImageSource{
+ expectedDigest: commonFixtureConfigDigest,
+ f: func() (io.ReadCloser, int64, error) {
+ return io.NopCloser(bytes.NewReader(realConfigJSON)), int64(len(realConfigJSON)), nil
+ },
+ },
+ ref: ref,
+ }
+}
+
+type memoryImageDest struct {
+ ref reference.Named
+ storedBlobs map[digest.Digest][]byte
+}
+
+func (d *memoryImageDest) Reference() types.ImageReference {
+ return refImageReferenceMock{ref: d.ref}
+}
+func (d *memoryImageDest) Close() error {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) SupportedManifestMIMETypes() []string {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) SupportsSignatures(ctx context.Context) error {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) DesiredLayerCompression() types.LayerCompression {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) AcceptsForeignLayerURLs() bool {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) MustMatchRuntimeOS() bool {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) IgnoresEmbeddedDockerReference() bool {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) HasThreadSafePutBlob() bool {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) PutBlob(ctx context.Context, stream io.Reader, inputInfo types.BlobInfo, cache types.BlobInfoCache, isConfig bool) (types.BlobInfo, error) {
+ if d.storedBlobs == nil {
+ d.storedBlobs = make(map[digest.Digest][]byte)
+ }
+ if inputInfo.Digest == "" {
+ panic("inputInfo.Digest unexpectedly empty")
+ }
+ contents, err := io.ReadAll(stream)
+ if err != nil {
+ return types.BlobInfo{}, err
+ }
+ d.storedBlobs[inputInfo.Digest] = contents
+ return types.BlobInfo{Digest: inputInfo.Digest, Size: int64(len(contents))}, nil
+}
+func (d *memoryImageDest) TryReusingBlob(context.Context, types.BlobInfo, types.BlobInfoCache, bool) (bool, types.BlobInfo, error) {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) PutManifest(context.Context, []byte, *digest.Digest) error {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) PutSignatures(ctx context.Context, signatures [][]byte, instanceDigest *digest.Digest) error {
+ panic("Unexpected call to a mock function")
+}
+func (d *memoryImageDest) Commit(context.Context, types.UnparsedImage) error {
+ panic("Unexpected call to a mock function")
+}
+
+// modifiedLayerInfos returns two identical (but separately allocated) copies of
+// layers from input, where the size and digest of each item is predictably modified from the original in input.
+// (This is used to test ManifestUpdateOptions.LayerInfos handling.)
+func modifiedLayerInfos(t *testing.T, input []types.BlobInfo) ([]types.BlobInfo, []types.BlobInfo) {
+ modified := []types.BlobInfo{}
+ for _, blob := range input {
+ b2 := blob
+ oldDigest, err := hex.DecodeString(b2.Digest.Encoded())
+ require.NoError(t, err)
+ oldDigest[len(oldDigest)-1] ^= 1
+ b2.Digest = digest.NewDigestFromEncoded(b2.Digest.Algorithm(), hex.EncodeToString(oldDigest))
+ b2.Size ^= 1
+ modified = append(modified, b2)
+ }
+
+ copy := slices.Clone(modified)
+ return modified, copy
+}
+
+func TestManifestSchema2UpdatedImage(t *testing.T) {
+ originalSrc := newSchema2ImageSource(t, "httpd:latest")
+ original := manifestSchema2FromFixture(t, originalSrc, "schema2.json", false)
+
+ // LayerInfos:
+ layerInfos := append(slices.Clone(original.LayerInfos()[1:]), original.LayerInfos()[0])
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfos,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, layerInfos, res.LayerInfos())
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: append(layerInfos, layerInfos[0]),
+ })
+ assert.Error(t, err)
+
+ // EmbeddedDockerReference:
+ // … is ignored
+ embeddedRef, err := reference.ParseNormalizedNamed("busybox")
+ require.NoError(t, err)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ EmbeddedDockerReference: embeddedRef,
+ })
+ require.NoError(t, err)
+ nonEmbeddedRef, err := reference.ParseNormalizedNamed("notbusybox:notlatest")
+ require.NoError(t, err)
+ conflicts := res.EmbeddedDockerReferenceConflicts(nonEmbeddedRef)
+ assert.False(t, conflicts)
+
+ // ManifestMIMEType:
+ // Only smoke-test the valid conversions, detailed tests are below. (This also verifies that “original” is not affected.)
+ for _, mime := range []string{
+ manifest.DockerV2Schema1MediaType,
+ manifest.DockerV2Schema1SignedMediaType,
+ } {
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: mime,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: &memoryImageDest{ref: originalSrc.ref},
+ },
+ })
+ assert.NoError(t, err, mime)
+ }
+ for _, mime := range []string{
+ manifest.DockerV2Schema2MediaType, // This indicates a confused caller, not a no-op
+ "this is invalid",
+ } {
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: mime,
+ })
+ assert.Error(t, err, mime)
+ }
+
+ // m hasn’t been changed:
+ m2 := manifestSchema2FromFixture(t, originalSrc, "schema2.json", false)
+ typedOriginal, ok := original.(*manifestSchema2)
+ require.True(t, ok)
+ typedM2, ok := m2.(*manifestSchema2)
+ require.True(t, ok)
+ assert.Equal(t, *typedM2, *typedOriginal)
+}
+
+func TestConvertToManifestOCI(t *testing.T) {
+ originalSrc := newSchema2ImageSource(t, "httpd-copy:latest")
+ original := manifestSchema2FromFixture(t, originalSrc, "schema2.json", false)
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ })
+ require.NoError(t, err)
+
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, mt)
+ assertJSONEqualsFixture(t, convertedJSON, "schema2-to-oci1.json")
+
+ convertedConfig, err := res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "schema2-to-oci1-config.json")
+
+ // Conversion to OCI with encryption is possible.
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfosWithCryptoOperation(original.LayerInfos(), types.Encrypt),
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ })
+ require.NoError(t, err)
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, mt)
+ // Layers have been updated as expected
+ ociManifest, err := manifestOCI1FromManifest(originalSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ },
+ }, ociManifest.LayerInfos())
+}
+
+func TestConvertToManifestOCIAllMediaTypes(t *testing.T) {
+ originalSrc := newSchema2ImageSource(t, "httpd-copy:latest")
+ original := manifestSchema2FromFixture(t, originalSrc, "schema2-all-media-types.json", false)
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ })
+ require.NoError(t, err)
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, mt)
+ assertJSONEqualsFixture(t, convertedJSON, "schema2-all-media-types-to-oci1.json")
+
+ convertedConfig, err := res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "schema2-to-oci1-config.json")
+}
+
+func TestConvertToOCIWithInvalidMIMEType(t *testing.T) {
+ originalSrc := newSchema2ImageSource(t, "httpd-copy:latest")
+ manifestSchema2FromFixture(t, originalSrc, "schema2-invalid-media-type.json", true)
+}
+
+func TestConvertToManifestSchema1(t *testing.T) {
+ originalSrc := newSchema2ImageSource(t, "httpd-copy:latest")
+ original := manifestSchema2FromFixture(t, originalSrc, "schema2.json", false)
+ memoryDest := &memoryImageDest{ref: originalSrc.ref}
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ require.NoError(t, err)
+
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, mt)
+
+ // schema2-to-schema1-by-docker.json is the result of asking the Docker Hub for a schema1 manifest,
+ // except that we have replaced "name" to verify that the ref from
+ // memoryDest, not from originalSrc, is used.
+ assertJSONEqualsFixture(t, convertedJSON, "schema2-to-schema1-by-docker.json", "signatures")
+
+ assert.Equal(t, GzippedEmptyLayer, memoryDest.storedBlobs[GzippedEmptyLayerDigest])
+
+ // Conversion to schema1 together with changing LayerInfos works as expected (which requires
+ // handling schema1 empty layers):
+ updatedLayers, updatedLayersCopy := modifiedLayerInfos(t, original.LayerInfos())
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, mt)
+ // Layers have been updated as expected
+ s1Manifest, err := manifestSchema1FromManifest(convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5ba", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680d", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a8", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25908", Size: -1},
+ {Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fb", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ }, s1Manifest.LayerInfos())
+
+ // Conversion to schema1 with encryption fails
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfosWithCryptoOperation(original.LayerInfos(), types.Encrypt),
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Error(t, err)
+
+ // FIXME? Test also the various failure cases, if only to see that we don't crash?
+}
+
+func TestConvertSchema2ToManifestOCIWithAnnotations(t *testing.T) {
+ // Test when converting an image from schema 2 (which doesn't support certain fields like
+ // URLs, annotations, etc.) to an OCI image (which supports those fields),
+ // that UpdatedImage propagates the features to the converted manifest.
+ originalSrc := newSchema2ImageSource(t, "httpd-copy:latest")
+ original := manifestSchema2FromFixture(t, originalSrc, "schema2.json", false)
+ layerInfoOverwrites := []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ URLs: []string{
+ "https://layer.url",
+ },
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ Annotations: map[string]string{
+ "test-annotation-2": "two",
+ },
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ }
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: imgspecv1.MediaTypeImageManifest,
+ LayerInfos: layerInfoOverwrites,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, res.LayerInfos(), layerInfoOverwrites)
+
+ // Doing this with schema2 should fail
+ originalSrc = newSchema2ImageSource(t, "httpd-copy:latest")
+ original = manifestSchema2FromFixture(t, originalSrc, "schema2.json", false)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: "",
+ LayerInfos: layerInfoOverwrites,
+ })
+ require.NoError(t, err)
+ assert.NotEqual(t, res.LayerInfos(), layerInfoOverwrites)
+}
+
+func TestManifestSchema2CanChangeLayerCompression(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestSchema2FromFixture(t, mocks.ForbiddenImageSource{}, "schema2.json", false),
+ manifestSchema2FromComponentsLikeFixture(nil),
+ } {
+ assert.True(t, m.CanChangeLayerCompression(manifest.DockerV2Schema2LayerMediaType))
+ // Some projects like to use squashfs and other unspecified formats for layers; don’t touch those.
+ assert.False(t, m.CanChangeLayerCompression("a completely unknown and quite possibly invalid MIME type"))
+ }
+}
diff --git a/internal/image/fixtures/oci1-all-media-types-config.json b/internal/image/fixtures/oci1-all-media-types-config.json
new file mode 100644
index 0000000..cd17d26
--- /dev/null
+++ b/internal/image/fixtures/oci1-all-media-types-config.json
@@ -0,0 +1,161 @@
+{
+ "architecture": "amd64",
+ "config": {
+ "Hostname": "383850eeb47b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "httpd-foreground"
+ ],
+ "ArgsEscaped": true,
+ "Image": "sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd",
+ "Volumes": null,
+ "WorkingDir": "/usr/local/apache2",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {}
+ },
+ "container": "8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69",
+ "container_config": {
+ "Hostname": "383850eeb47b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "/bin/sh",
+ "-c",
+ "#(nop) ",
+ "CMD [\"httpd-foreground\"]"
+ ],
+ "ArgsEscaped": true,
+ "Image": "sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd",
+ "Volumes": null,
+ "WorkingDir": "/usr/local/apache2",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {}
+ },
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "docker_version": "1.12.1",
+ "history": [
+ {
+ "created": "2016-09-23T18:08:50.537223822Z",
+ "created_by": "/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "
+ },
+ {
+ "created": "2016-09-23T18:08:51.133779867Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:40.725768956Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.037788416Z",
+ "created_by": "/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.990121202Z",
+ "created_by": "/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""
+ },
+ {
+ "created": "2016-09-23T19:16:42.339911155Z",
+ "created_by": "/bin/sh -c #(nop) WORKDIR /usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:54.948461741Z",
+ "created_by": "/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"
+ },
+ {
+ "created": "2016-09-23T19:16:55.321573403Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:55.629947307Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:03.705796801Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:04.009782822Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:44.585743332Z",
+ "created_by": "/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"
+ },
+ {
+ "created": "2016-09-23T23:20:45.127455562Z",
+ "created_by": "/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "
+ },
+ {
+ "created": "2016-09-23T23:20:45.453934921Z",
+ "created_by": "/bin/sh -c #(nop) EXPOSE 80/tcp",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"httpd-foreground\"]",
+ "empty_layer": true
+ },
+ {
+ "created": "2023-10-01T02:03:04.56789764Z",
+ "created_by": "/bin/sh echo something > last"
+ }
+ ],
+ "os": "linux",
+ "rootfs": {
+ "type": "layers",
+ "diff_ids": [
+ "sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab",
+ "sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c",
+ "sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56",
+ "sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9",
+ "sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b",
+ "sha256:1111111111111111111111111111111111111111111111111111111111111111"
+ ]
+ }
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-all-media-types-to-schema2-config.json b/internal/image/fixtures/oci1-all-media-types-to-schema2-config.json
new file mode 100644
index 0000000..cd17d26
--- /dev/null
+++ b/internal/image/fixtures/oci1-all-media-types-to-schema2-config.json
@@ -0,0 +1,161 @@
+{
+ "architecture": "amd64",
+ "config": {
+ "Hostname": "383850eeb47b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "httpd-foreground"
+ ],
+ "ArgsEscaped": true,
+ "Image": "sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd",
+ "Volumes": null,
+ "WorkingDir": "/usr/local/apache2",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {}
+ },
+ "container": "8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69",
+ "container_config": {
+ "Hostname": "383850eeb47b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "/bin/sh",
+ "-c",
+ "#(nop) ",
+ "CMD [\"httpd-foreground\"]"
+ ],
+ "ArgsEscaped": true,
+ "Image": "sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd",
+ "Volumes": null,
+ "WorkingDir": "/usr/local/apache2",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {}
+ },
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "docker_version": "1.12.1",
+ "history": [
+ {
+ "created": "2016-09-23T18:08:50.537223822Z",
+ "created_by": "/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "
+ },
+ {
+ "created": "2016-09-23T18:08:51.133779867Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:40.725768956Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.037788416Z",
+ "created_by": "/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.990121202Z",
+ "created_by": "/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""
+ },
+ {
+ "created": "2016-09-23T19:16:42.339911155Z",
+ "created_by": "/bin/sh -c #(nop) WORKDIR /usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:54.948461741Z",
+ "created_by": "/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"
+ },
+ {
+ "created": "2016-09-23T19:16:55.321573403Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:55.629947307Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:03.705796801Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:04.009782822Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:44.585743332Z",
+ "created_by": "/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"
+ },
+ {
+ "created": "2016-09-23T23:20:45.127455562Z",
+ "created_by": "/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "
+ },
+ {
+ "created": "2016-09-23T23:20:45.453934921Z",
+ "created_by": "/bin/sh -c #(nop) EXPOSE 80/tcp",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"httpd-foreground\"]",
+ "empty_layer": true
+ },
+ {
+ "created": "2023-10-01T02:03:04.56789764Z",
+ "created_by": "/bin/sh echo something > last"
+ }
+ ],
+ "os": "linux",
+ "rootfs": {
+ "type": "layers",
+ "diff_ids": [
+ "sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab",
+ "sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c",
+ "sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56",
+ "sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9",
+ "sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b",
+ "sha256:1111111111111111111111111111111111111111111111111111111111111111"
+ ]
+ }
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-all-media-types-to-schema2.json b/internal/image/fixtures/oci1-all-media-types-to-schema2.json
new file mode 100644
index 0000000..702addf
--- /dev/null
+++ b/internal/image/fixtures/oci1-all-media-types-to-schema2.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": 4651,
+ "digest": "sha256:a13a0762ab7bed51a1b49adec0a702b1cd99294fd460a025b465bcfb7b152745"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.zstd",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 152,
+ "digest": "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-all-media-types.json b/internal/image/fixtures/oci1-all-media-types.json
new file mode 100644
index 0000000..e92fe2c
--- /dev/null
+++ b/internal/image/fixtures/oci1-all-media-types.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 4651,
+ "digest": "sha256:94ac69e4413476d061116c9d05757e46a0afc744e8b9886f75cf7f6f14c78fb3"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 152,
+ "digest": "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+zstd",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-artifact.json b/internal/image/fixtures/oci1-artifact.json
new file mode 100644
index 0000000..9e54409
--- /dev/null
+++ b/internal/image/fixtures/oci1-artifact.json
@@ -0,0 +1,43 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.custom.artifact.config.v1+json",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f",
+ "annotations": {
+ "test-annotation-1": "one"
+ }
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ "urls": ["https://layer.url"]
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ "annotations": {
+ "test-annotation-2": "two"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+}
diff --git a/internal/image/fixtures/oci1-config-extra-fields.json b/internal/image/fixtures/oci1-config-extra-fields.json
new file mode 100644
index 0000000..1d670d5
--- /dev/null
+++ b/internal/image/fixtures/oci1-config-extra-fields.json
@@ -0,0 +1,158 @@
+{
+ "extra-string-field": "string",
+ "extra-object": {"foo":"bar"},
+ "architecture": "amd64",
+ "config": {
+ "Hostname": "383850eeb47b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "httpd-foreground"
+ ],
+ "ArgsEscaped": true,
+ "Image": "sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd",
+ "Volumes": null,
+ "WorkingDir": "/usr/local/apache2",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {}
+ },
+ "container": "8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69",
+ "container_config": {
+ "Hostname": "383850eeb47b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "/bin/sh",
+ "-c",
+ "#(nop) ",
+ "CMD [\"httpd-foreground\"]"
+ ],
+ "ArgsEscaped": true,
+ "Image": "sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd",
+ "Volumes": null,
+ "WorkingDir": "/usr/local/apache2",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {}
+ },
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "docker_version": "1.12.1",
+ "history": [
+ {
+ "created": "2016-09-23T18:08:50.537223822Z",
+ "created_by": "/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "
+ },
+ {
+ "created": "2016-09-23T18:08:51.133779867Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:40.725768956Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.037788416Z",
+ "created_by": "/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.990121202Z",
+ "created_by": "/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""
+ },
+ {
+ "created": "2016-09-23T19:16:42.339911155Z",
+ "created_by": "/bin/sh -c #(nop) WORKDIR /usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:54.948461741Z",
+ "created_by": "/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"
+ },
+ {
+ "created": "2016-09-23T19:16:55.321573403Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:55.629947307Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:03.705796801Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:04.009782822Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:44.585743332Z",
+ "created_by": "/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"
+ },
+ {
+ "created": "2016-09-23T23:20:45.127455562Z",
+ "created_by": "/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "
+ },
+ {
+ "created": "2016-09-23T23:20:45.453934921Z",
+ "created_by": "/bin/sh -c #(nop) EXPOSE 80/tcp",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"httpd-foreground\"]",
+ "empty_layer": true
+ }
+ ],
+ "os": "linux",
+ "rootfs": {
+ "type": "layers",
+ "diff_ids": [
+ "sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab",
+ "sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c",
+ "sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56",
+ "sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9",
+ "sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b"
+ ]
+ }
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-config.json b/internal/image/fixtures/oci1-config.json
new file mode 100644
index 0000000..f49230e
--- /dev/null
+++ b/internal/image/fixtures/oci1-config.json
@@ -0,0 +1 @@
+{"architecture":"amd64","config":{"Hostname":"383850eeb47b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","HTTPD_PREFIX=/usr/local/apache2","HTTPD_VERSION=2.4.23","HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"],"Cmd":["httpd-foreground"],"ArgsEscaped":true,"Image":"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd","Volumes":null,"WorkingDir":"/usr/local/apache2","Entrypoint":null,"OnBuild":[],"Labels":{}},"container":"8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69","container_config":{"Hostname":"383850eeb47b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","HTTPD_PREFIX=/usr/local/apache2","HTTPD_VERSION=2.4.23","HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"httpd-foreground\"]"],"ArgsEscaped":true,"Image":"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd","Volumes":null,"WorkingDir":"/usr/local/apache2","Entrypoint":null,"OnBuild":[],"Labels":{}},"created":"2016-09-23T23:20:45.78976459Z","docker_version":"1.12.1","history":[{"created":"2016-09-23T18:08:50.537223822Z","created_by":"/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "},{"created":"2016-09-23T18:08:51.133779867Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/bash\"]","empty_layer":true},{"created":"2016-09-23T19:16:40.725768956Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2","empty_layer":true},{"created":"2016-09-23T19:16:41.037788416Z","created_by":"/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","empty_layer":true},{"created":"2016-09-23T19:16:41.990121202Z","created_by":"/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""},{"created":"2016-09-23T19:16:42.339911155Z","created_by":"/bin/sh -c #(nop) WORKDIR /usr/local/apache2","empty_layer":true},{"created":"2016-09-23T19:16:54.948461741Z","created_by":"/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"},{"created":"2016-09-23T19:16:55.321573403Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23","empty_layer":true},{"created":"2016-09-23T19:16:55.629947307Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","empty_layer":true},{"created":"2016-09-23T23:19:03.705796801Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","empty_layer":true},{"created":"2016-09-23T23:19:04.009782822Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc","empty_layer":true},{"created":"2016-09-23T23:20:44.585743332Z","created_by":"/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"},{"created":"2016-09-23T23:20:45.127455562Z","created_by":"/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "},{"created":"2016-09-23T23:20:45.453934921Z","created_by":"/bin/sh -c #(nop) EXPOSE 80/tcp","empty_layer":true},{"created":"2016-09-23T23:20:45.78976459Z","created_by":"/bin/sh -c #(nop) CMD [\"httpd-foreground\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab","sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c","sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56","sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9","sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b"]}} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-extra-config-fields.json b/internal/image/fixtures/oci1-extra-config-fields.json
new file mode 100644
index 0000000..b297f4a
--- /dev/null
+++ b/internal/image/fixtures/oci1-extra-config-fields.json
@@ -0,0 +1,43 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 7693,
+ "digest": "sha256:7f2a783ee2f07826b1856e68a40c930cd0430d6e7d4a88c29c2c8b7718706e74",
+ "annotations": {
+ "test-annotation-1": "one"
+ }
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ "urls": ["https://layer.url"]
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ "annotations": {
+ "test-annotation-2": "two"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+}
diff --git a/internal/image/fixtures/oci1-invalid-media-type.json b/internal/image/fixtures/oci1-invalid-media-type.json
new file mode 100644
index 0000000..7b7d06e
--- /dev/null
+++ b/internal/image/fixtures/oci1-invalid-media-type.json
@@ -0,0 +1,15 @@
+{
+ "schemaVersion": 2,
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+invalid-suffix",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-to-schema1.json b/internal/image/fixtures/oci1-to-schema1.json
new file mode 100644
index 0000000..a85b3ff
--- /dev/null
+++ b/internal/image/fixtures/oci1-to-schema1.json
@@ -0,0 +1 @@
+{"name":"library/httpd-copy","tag":"latest","architecture":"amd64","fsLayers":[{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"},{"blobSum":"sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"},{"blobSum":"sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"}],"history":[{"v1Compatibility":"{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"383850eeb47b\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":{\"80/tcp\":{}},\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"HTTPD_PREFIX=/usr/local/apache2\",\"HTTPD_VERSION=2.4.23\",\"HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f\",\"HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\\u0026filename=httpd/httpd-2.4.23.tar.bz2\",\"HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc\"],\"Cmd\":[\"httpd-foreground\"],\"ArgsEscaped\":true,\"Image\":\"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd\",\"Volumes\":null,\"WorkingDir\":\"/usr/local/apache2\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{}},\"container\":\"8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69\",\"container_config\":{\"Hostname\":\"383850eeb47b\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":{\"80/tcp\":{}},\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"HTTPD_PREFIX=/usr/local/apache2\",\"HTTPD_VERSION=2.4.23\",\"HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f\",\"HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\\u0026filename=httpd/httpd-2.4.23.tar.bz2\",\"HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"httpd-foreground\\\"]\"],\"ArgsEscaped\":true,\"Image\":\"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd\",\"Volumes\":null,\"WorkingDir\":\"/usr/local/apache2\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{}},\"created\":\"2016-09-23T23:20:45.78976459Z\",\"docker_version\":\"1.12.1\",\"id\":\"dca7323f9c839837493199d63263083d94f5eb1796d7bd04ca8374c4e9d3749a\",\"os\":\"linux\",\"parent\":\"1b750729af47c9a802c8d14b0d327d3ad5ecdce5ae773ac728a0263315b914f4\",\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"1b750729af47c9a802c8d14b0d327d3ad5ecdce5ae773ac728a0263315b914f4\",\"parent\":\"3ef2f186f8b0a2fd2d95f5a1f1cd213f5fb0a6e51b0a8dfbe2ec7003a788ff9a\",\"created\":\"2016-09-23T23:20:45.453934921Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) EXPOSE 80/tcp\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"3ef2f186f8b0a2fd2d95f5a1f1cd213f5fb0a6e51b0a8dfbe2ec7003a788ff9a\",\"parent\":\"dbbb5c772ba968f675ebdb1968a2fbcf3cf53c0c85ff4e3329619e3735c811e6\",\"created\":\"2016-09-23T23:20:45.127455562Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ \"]}}"},{"v1Compatibility":"{\"id\":\"dbbb5c772ba968f675ebdb1968a2fbcf3cf53c0c85ff4e3329619e3735c811e6\",\"parent\":\"d264ded964bb52f78c8905c9e6c5f2b8526ef33f371981f0651f3fb0164ad4a7\",\"created\":\"2016-09-23T23:20:44.585743332Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c set -x \\t\\u0026\\u0026 buildDeps=' \\t\\tbzip2 \\t\\tca-certificates \\t\\tgcc \\t\\tlibpcre++-dev \\t\\tlibssl-dev \\t\\tmake \\t\\twget \\t' \\t\\u0026\\u0026 apt-get update \\t\\u0026\\u0026 apt-get install -y --no-install-recommends $buildDeps \\t\\u0026\\u0026 rm -r /var/lib/apt/lists/* \\t\\t\\u0026\\u0026 wget -O httpd.tar.bz2 \\\"$HTTPD_BZ2_URL\\\" \\t\\u0026\\u0026 echo \\\"$HTTPD_SHA1 *httpd.tar.bz2\\\" | sha1sum -c - \\t\\u0026\\u0026 wget -O httpd.tar.bz2.asc \\\"$HTTPD_ASC_URL\\\" \\t\\u0026\\u0026 export GNUPGHOME=\\\"$(mktemp -d)\\\" \\t\\u0026\\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \\t\\u0026\\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \\t\\u0026\\u0026 rm -r \\\"$GNUPGHOME\\\" httpd.tar.bz2.asc \\t\\t\\u0026\\u0026 mkdir -p src \\t\\u0026\\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \\t\\u0026\\u0026 rm httpd.tar.bz2 \\t\\u0026\\u0026 cd src \\t\\t\\u0026\\u0026 ./configure \\t\\t--prefix=\\\"$HTTPD_PREFIX\\\" \\t\\t--enable-mods-shared=reallyall \\t\\u0026\\u0026 make -j\\\"$(nproc)\\\" \\t\\u0026\\u0026 make install \\t\\t\\u0026\\u0026 cd .. \\t\\u0026\\u0026 rm -r src \\t\\t\\u0026\\u0026 sed -ri \\t\\t-e 's!^(\\\\s*CustomLog)\\\\s+\\\\S+!\\\\1 /proc/self/fd/1!g' \\t\\t-e 's!^(\\\\s*ErrorLog)\\\\s+\\\\S+!\\\\1 /proc/self/fd/2!g' \\t\\t\\\"$HTTPD_PREFIX/conf/httpd.conf\\\" \\t\\t\\u0026\\u0026 apt-get purge -y --auto-remove $buildDeps\"]}}"},{"v1Compatibility":"{\"id\":\"d264ded964bb52f78c8905c9e6c5f2b8526ef33f371981f0651f3fb0164ad4a7\",\"parent\":\"fd6f8d569a8a6d2a95f797494ab3cee7a47693dde647210b236a141f76b5c5fd\",\"created\":\"2016-09-23T23:19:04.009782822Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"fd6f8d569a8a6d2a95f797494ab3cee7a47693dde647210b236a141f76b5c5fd\",\"parent\":\"5e2578d171daa47c0eeb55e592b4e3bd28a0946a75baed58e4d4dd315c5d5780\",\"created\":\"2016-09-23T23:19:03.705796801Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\\u0026filename=httpd/httpd-2.4.23.tar.bz2\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"5e2578d171daa47c0eeb55e592b4e3bd28a0946a75baed58e4d4dd315c5d5780\",\"parent\":\"1912159ee5bea8d7fde49b85012f90c47bceb3f09e4082b112b1f06a3f339c53\",\"created\":\"2016-09-23T19:16:55.629947307Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"1912159ee5bea8d7fde49b85012f90c47bceb3f09e4082b112b1f06a3f339c53\",\"parent\":\"3bfb089ca9d4bb73a9016e44a2c6f908b701f97704433305c419f75e8559d8a2\",\"created\":\"2016-09-23T19:16:55.321573403Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"3bfb089ca9d4bb73a9016e44a2c6f908b701f97704433305c419f75e8559d8a2\",\"parent\":\"ae1ece73de4d0365c8b8ab45ba0bf6b1efa4213c16a4903b89341b704d101c3c\",\"created\":\"2016-09-23T19:16:54.948461741Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c apt-get update \\t\\u0026\\u0026 apt-get install -y --no-install-recommends \\t\\tlibapr1 \\t\\tlibaprutil1 \\t\\tlibaprutil1-ldap \\t\\tlibapr1-dev \\t\\tlibaprutil1-dev \\t\\tlibpcre++0 \\t\\tlibssl1.0.0 \\t\\u0026\\u0026 rm -r /var/lib/apt/lists/*\"]}}"},{"v1Compatibility":"{\"id\":\"ae1ece73de4d0365c8b8ab45ba0bf6b1efa4213c16a4903b89341b704d101c3c\",\"parent\":\"bffbcb416f40e0bd3ebae202403587bfd41829cd1e0d538b66f29adce40c6408\",\"created\":\"2016-09-23T19:16:42.339911155Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) WORKDIR /usr/local/apache2\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"bffbcb416f40e0bd3ebae202403587bfd41829cd1e0d538b66f29adce40c6408\",\"parent\":\"7b27731a3363efcb6b0520962d544471745aae15664920dffe690b4fdb410d80\",\"created\":\"2016-09-23T19:16:41.990121202Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c mkdir -p \\\"$HTTPD_PREFIX\\\" \\t\\u0026\\u0026 chown www-data:www-data \\\"$HTTPD_PREFIX\\\"\"]}}"},{"v1Compatibility":"{\"id\":\"7b27731a3363efcb6b0520962d544471745aae15664920dffe690b4fdb410d80\",\"parent\":\"57a0a421f1acbc1fe6b88b32d3d1c3c0388ff1958b97f95dd0e3a599b810499b\",\"created\":\"2016-09-23T19:16:41.037788416Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"57a0a421f1acbc1fe6b88b32d3d1c3c0388ff1958b97f95dd0e3a599b810499b\",\"parent\":\"faeaf6fdfdcbb18d68c12db9683a02428bab83962a493de88b4c7b1ec941db8f\",\"created\":\"2016-09-23T19:16:40.725768956Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"faeaf6fdfdcbb18d68c12db9683a02428bab83962a493de88b4c7b1ec941db8f\",\"parent\":\"d0c4f1eb7dc8f4dae2b45fe5c0cf4cfc70e5be85d933f5f5f4deb59f134fb520\",\"created\":\"2016-09-23T18:08:51.133779867Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) CMD [\\\"/bin/bash\\\"]\"]},\"throwaway\":true}"},{"v1Compatibility":"{\"id\":\"d0c4f1eb7dc8f4dae2b45fe5c0cf4cfc70e5be85d933f5f5f4deb59f134fb520\",\"created\":\"2016-09-23T18:08:50.537223822Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / \"]}}"}],"schemaVersion":1,"signatures":[{"header":{"jwk":{"crv":"P-256","kid":"Q3ZE:RPLV:YRWL:CJGY:3YUV:CVRB:KOZN:DPRO:QZKD:B7KB:4FJ5:XUDM","kty":"EC","x":"iIJKPTtzobd73WmVmIoRSGkHWQB86bL5BBw9-YWcVjA","y":"3fiBd8u2fXRc5DZG20gQWQ8LUvTuPRqkU0e672ymn-8"},"alg":"ES256"},"signature":"I1uNEFT2P64rwc7dajzBOCD9o4DB4W7xbWQRxOgWm43Py1_N3omkvqUStMeUQsQVjNqje6NQyVzQzOACDHsPYg","protected":"eyJmb3JtYXRMZW5ndGgiOjEwMTEyLCJmb3JtYXRUYWlsIjoiZlEiLCJ0aW1lIjoiMjAyMC0wMy0yNVQyMzo0NzowOVoifQ"}]}
diff --git a/internal/image/fixtures/oci1-to-schema2-config.json b/internal/image/fixtures/oci1-to-schema2-config.json
new file mode 100644
index 0000000..f49230e
--- /dev/null
+++ b/internal/image/fixtures/oci1-to-schema2-config.json
@@ -0,0 +1 @@
+{"architecture":"amd64","config":{"Hostname":"383850eeb47b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","HTTPD_PREFIX=/usr/local/apache2","HTTPD_VERSION=2.4.23","HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"],"Cmd":["httpd-foreground"],"ArgsEscaped":true,"Image":"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd","Volumes":null,"WorkingDir":"/usr/local/apache2","Entrypoint":null,"OnBuild":[],"Labels":{}},"container":"8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69","container_config":{"Hostname":"383850eeb47b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","HTTPD_PREFIX=/usr/local/apache2","HTTPD_VERSION=2.4.23","HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"httpd-foreground\"]"],"ArgsEscaped":true,"Image":"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd","Volumes":null,"WorkingDir":"/usr/local/apache2","Entrypoint":null,"OnBuild":[],"Labels":{}},"created":"2016-09-23T23:20:45.78976459Z","docker_version":"1.12.1","history":[{"created":"2016-09-23T18:08:50.537223822Z","created_by":"/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "},{"created":"2016-09-23T18:08:51.133779867Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/bash\"]","empty_layer":true},{"created":"2016-09-23T19:16:40.725768956Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2","empty_layer":true},{"created":"2016-09-23T19:16:41.037788416Z","created_by":"/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","empty_layer":true},{"created":"2016-09-23T19:16:41.990121202Z","created_by":"/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""},{"created":"2016-09-23T19:16:42.339911155Z","created_by":"/bin/sh -c #(nop) WORKDIR /usr/local/apache2","empty_layer":true},{"created":"2016-09-23T19:16:54.948461741Z","created_by":"/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"},{"created":"2016-09-23T19:16:55.321573403Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23","empty_layer":true},{"created":"2016-09-23T19:16:55.629947307Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","empty_layer":true},{"created":"2016-09-23T23:19:03.705796801Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","empty_layer":true},{"created":"2016-09-23T23:19:04.009782822Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc","empty_layer":true},{"created":"2016-09-23T23:20:44.585743332Z","created_by":"/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"},{"created":"2016-09-23T23:20:45.127455562Z","created_by":"/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "},{"created":"2016-09-23T23:20:45.453934921Z","created_by":"/bin/sh -c #(nop) EXPOSE 80/tcp","empty_layer":true},{"created":"2016-09-23T23:20:45.78976459Z","created_by":"/bin/sh -c #(nop) CMD [\"httpd-foreground\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab","sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c","sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56","sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9","sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b"]}} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1-to-schema2.json b/internal/image/fixtures/oci1-to-schema2.json
new file mode 100644
index 0000000..50aa6dc
--- /dev/null
+++ b/internal/image/fixtures/oci1-to-schema2.json
@@ -0,0 +1,37 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ "urls": ["https://layer.url"]
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/oci1.encrypted.json b/internal/image/fixtures/oci1.encrypted.json
new file mode 100644
index 0000000..c6c523e
--- /dev/null
+++ b/internal/image/fixtures/oci1.encrypted.json
@@ -0,0 +1,43 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f",
+ "annotations": {
+ "test-annotation-1": "one"
+ }
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ "size": 51354364,
+ "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ "size": 150,
+ "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ "size": 11739507,
+ "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "urls": ["https://layer.url"]
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ "size": 8841833,
+ "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "annotations": {
+ "test-annotation-2": "two"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip+encrypted",
+ "size": 291,
+ "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
+ }
+ ]
+}
diff --git a/internal/image/fixtures/oci1.json b/internal/image/fixtures/oci1.json
new file mode 100644
index 0000000..26efc23
--- /dev/null
+++ b/internal/image/fixtures/oci1.json
@@ -0,0 +1,43 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f",
+ "annotations": {
+ "test-annotation-1": "one"
+ }
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ "urls": ["https://layer.url"]
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ "annotations": {
+ "test-annotation-2": "two"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+}
diff --git a/internal/image/fixtures/schema1-for-oci-config.json b/internal/image/fixtures/schema1-for-oci-config.json
new file mode 100644
index 0000000..ee58257
--- /dev/null
+++ b/internal/image/fixtures/schema1-for-oci-config.json
@@ -0,0 +1,29 @@
+{
+ "schemaVersion": 1,
+ "name": "google_containers/pause-amd64",
+ "tag": "3.0",
+ "architecture": "amd64",
+ "fsLayers": [
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:f112334343777b75be77ec1f835e3bbbe7d7bd46e27b6a2ae35c6b3cfea0987c"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ }
+ ],
+ "history": [
+ {
+ "v1Compatibility": "{\"id\":\"bb497e16a2d55195649174d1fadac52b00fa2c14124d73009712606909286bc5\",\"parent\":\"f8e2eec424cf985b4e41d6423991433fb7a93c90f9acc73a5e7bee213b789c52\",\"created\":\"2016-05-04T06:26:41.522308365Z\",\"container\":\"a9873535145fe72b464d3055efbac36aab70d059914e221cbbd7fe3cac53ef6b\",\"container_config\":{\"Hostname\":\"95722352e41d\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ENTRYPOINT \\u0026{[\\\"/pause\\\"]}\"],\"Image\":\"f8e2eec424cf985b4e41d6423991433fb7a93c90f9acc73a5e7bee213b789c52\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":[\"/pause\"],\"OnBuild\":null,\"Labels\":{}},\"docker_version\":\"1.9.1\",\"config\":{\"Hostname\":\"95722352e41d\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"f8e2eec424cf985b4e41d6423991433fb7a93c90f9acc73a5e7bee213b789c52\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":[\"/pause\"],\"OnBuild\":null,\"Labels\":{}},\"architecture\":\"amd64\",\"os\":\"linux\"}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"f8e2eec424cf985b4e41d6423991433fb7a93c90f9acc73a5e7bee213b789c52\",\"parent\":\"bdb43c586e887b513a056722b50553727b255e3a3d9166f318632d4209963464\",\"created\":\"2016-05-04T06:26:41.091672218Z\",\"container\":\"e1b38778b023f25642273ed9e7f4846b4bf38b22a8b55755880b2e6ab6019811\",\"container_config\":{\"Hostname\":\"95722352e41d\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ADD file:b7eb6a5df9d5fbe509cac16ed89f8d6513a4362017184b14c6a5fae151eee5c5 in /pause\"],\"Image\":\"bdb43c586e887b513a056722b50553727b255e3a3d9166f318632d4209963464\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"docker_version\":\"1.9.1\",\"config\":{\"Hostname\":\"95722352e41d\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"bdb43c586e887b513a056722b50553727b255e3a3d9166f318632d4209963464\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":746888}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"bdb43c586e887b513a056722b50553727b255e3a3d9166f318632d4209963464\",\"created\":\"2016-05-04T06:26:40.628395649Z\",\"container\":\"95722352e41d57660259fbede4413d06889a28eb07a7302d2a7b3f9c71ceaa46\",\"container_config\":{\"Hostname\":\"95722352e41d\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ARG ARCH\"],\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.9.1\",\"config\":{\"Hostname\":\"95722352e41d\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\"}"
+ }
+ ],"signatures":[{"header":{"alg":"ES256","jwk":{"crv":"P-256","kid":"ORN4:M47W:3KP3:TZRZ:C3UF:5MFQ:INZV:TCMY:LHNV:EYQU:IRGJ:IJLJ","kty":"EC","x":"yJ0ZQ19NBZUQn8LV60sFEabhlgky9svozfK0VGVou7Y","y":"gOJScOkkLVY1f8aAx-6XXpVM5rJaDYLkCNJ1dvcQGMs"}},"protected":"eyJmb3JtYXRMZW5ndGgiOjQxMzMsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNi0wNS0wNFQwNjoyODo1MVoifQ","signature":"77_7DVx1IZ3PiKNnO7QnvoF7Sgik4GI4bnlVJdtQW461dSyYzd-nSdBmky8Jew3InEW8Cuv_t5w4GmOSwXvL7g"}]
+
+}
diff --git a/internal/image/fixtures/schema1-to-oci1-config.json b/internal/image/fixtures/schema1-to-oci1-config.json
new file mode 100644
index 0000000..950e225
--- /dev/null
+++ b/internal/image/fixtures/schema1-to-oci1-config.json
@@ -0,0 +1,82 @@
+{
+ "architecture": "amd64",
+ "config": {
+ "User": "nova",
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "container=oci",
+ "KOLLA_BASE_DISTRO=rhel",
+ "KOLLA_INSTALL_TYPE=binary",
+ "KOLLA_INSTALL_METATYPE=rhos",
+ "PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ "
+ ],
+ "Cmd": [
+ "kolla_start"
+ ],
+ "Labels": {
+ "Kolla-SHA": "5.0.0-39-g6f1b947b",
+ "architecture": "x86_64",
+ "authoritative-source-url": "registry.access.redhat.com",
+ "build-date": "2018-01-25T00:32:27.807261",
+ "com.redhat.build-host": "ip-10-29-120-186.ec2.internal",
+ "com.redhat.component": "openstack-nova-api-docker",
+ "description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "distribution-scope": "public",
+ "io.k8s.description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.k8s.display-name": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.openshift.tags": "rhosp osp openstack osp-12.0",
+ "kolla_version": "stable/pike",
+ "name": "rhosp12/openstack-nova-api",
+ "release": "20180124.1",
+ "summary": "Red Hat OpenStack Platform 12.0 nova-api",
+ "tripleo-common_version": "7.6.3-23-g4891cfe",
+ "url": "https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1",
+ "vcs-ref": "9b31243b7b448eb2fc3b6e2c96935b948f806e98",
+ "vcs-type": "git",
+ "vendor": "Red Hat, Inc.",
+ "version": "12.0",
+ "version-release": "12.0-20180124.1"
+ },
+ "ArgsEscaped": true
+ },
+ "created": "2018-01-25T00:37:48.268558Z",
+ "os": "linux",
+ "history": [
+ {
+ "comment": "Imported from -",
+ "created": "2017-11-21T16:47:27.755341705Z"
+ },
+ {
+ "author": "Red Hat, Inc.",
+ "created": "2017-11-21T16:49:37.292899Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/compose-rpms-1.repo'"
+ },
+ {
+ "created": "2018-01-24T21:40:32.494686Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'"
+ },
+ {
+ "created": "2018-01-24T22:00:57.807862Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'"
+ },
+ {
+ "created": "2018-01-24T23:08:25.300741Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'"
+ },
+ {
+ "created": "2018-01-25T00:37:48.268558Z",
+ "created_by": "/bin/sh -c #(nop) USER [nova]"
+ }
+ ],
+ "rootfs": {
+ "type": "layers",
+ "diff_ids": [
+ "sha256:e1d829eddb62dc49f1c56dbf8acd0c71299b3996115399de853a9d66d81b822f",
+ "sha256:02404b4d7e5d89b1383ca346b4462b199128aa4b238c5a2b2c186004ac148ba8",
+ "sha256:45fad80a4b1cec165c421eb570dec312d825bd8fac362e255028fa3f2169148d",
+ "sha256:7ddef8efd44586e54880ec4797458eac87b368544c438d7e7c63fbc0d9a7ae97",
+ "sha256:b56b16b6407ba1b86252e7e50f98f142cf6844fab42e4495d56ebb7ce559e2af",
+ "sha256:9bd63850e406167b4751f5050f6dc0ebd789bb5ef5e5c6c31ed062bda8c063e8"
+ ]
+ }
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema1-to-oci1.json b/internal/image/fixtures/schema1-to-oci1.json
new file mode 100644
index 0000000..0af1fbe
--- /dev/null
+++ b/internal/image/fixtures/schema1-to-oci1.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": -1,
+ "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 74876245,
+ "digest": "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 1239,
+ "digest": "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 78339724,
+ "digest": "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 76857203,
+ "digest": "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 25923380,
+ "digest": "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 23511300,
+ "digest": "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema1-to-schema2-config.json b/internal/image/fixtures/schema1-to-schema2-config.json
new file mode 100644
index 0000000..c182ded
--- /dev/null
+++ b/internal/image/fixtures/schema1-to-schema2-config.json
@@ -0,0 +1,163 @@
+{
+ "architecture": "amd64",
+ "config": {
+ "Hostname": "9428cdea83ba",
+ "Domainname": "",
+ "User": "nova",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "container=oci",
+ "KOLLA_BASE_DISTRO=rhel",
+ "KOLLA_INSTALL_TYPE=binary",
+ "KOLLA_INSTALL_METATYPE=rhos",
+ "PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ "
+ ],
+ "Cmd": [
+ "kolla_start"
+ ],
+ "Healthcheck": {
+ "Test": [
+ "CMD-SHELL",
+ "/openstack/healthcheck"
+ ]
+ },
+ "ArgsEscaped": true,
+ "Image": "3bf9afe371220b1eb1c57bec39b5a99ba976c36c92d964a1c014584f95f51e33",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {
+ "Kolla-SHA": "5.0.0-39-g6f1b947b",
+ "architecture": "x86_64",
+ "authoritative-source-url": "registry.access.redhat.com",
+ "build-date": "2018-01-25T00:32:27.807261",
+ "com.redhat.build-host": "ip-10-29-120-186.ec2.internal",
+ "com.redhat.component": "openstack-nova-api-docker",
+ "description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "distribution-scope": "public",
+ "io.k8s.description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.k8s.display-name": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.openshift.tags": "rhosp osp openstack osp-12.0",
+ "kolla_version": "stable/pike",
+ "name": "rhosp12/openstack-nova-api",
+ "release": "20180124.1",
+ "summary": "Red Hat OpenStack Platform 12.0 nova-api",
+ "tripleo-common_version": "7.6.3-23-g4891cfe",
+ "url": "https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1",
+ "vcs-ref": "9b31243b7b448eb2fc3b6e2c96935b948f806e98",
+ "vcs-type": "git",
+ "vendor": "Red Hat, Inc.",
+ "version": "12.0",
+ "version-release": "12.0-20180124.1"
+ }
+ },
+ "container_config": {
+ "Hostname": "9428cdea83ba",
+ "Domainname": "",
+ "User": "nova",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "container=oci",
+ "KOLLA_BASE_DISTRO=rhel",
+ "KOLLA_INSTALL_TYPE=binary",
+ "KOLLA_INSTALL_METATYPE=rhos",
+ "PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ "
+ ],
+ "Cmd": [
+ "/bin/sh",
+ "-c",
+ "#(nop) ",
+ "USER [nova]"
+ ],
+ "Healthcheck": {
+ "Test": [
+ "CMD-SHELL",
+ "/openstack/healthcheck"
+ ]
+ },
+ "ArgsEscaped": true,
+ "Image": "sha256:274ce4dcbeb09fa173a5d50203ae5cec28f456d1b8b59477b47a42bd74d068bf",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": null,
+ "OnBuild": [],
+ "Labels": {
+ "Kolla-SHA": "5.0.0-39-g6f1b947b",
+ "architecture": "x86_64",
+ "authoritative-source-url": "registry.access.redhat.com",
+ "build-date": "2018-01-25T00:32:27.807261",
+ "com.redhat.build-host": "ip-10-29-120-186.ec2.internal",
+ "com.redhat.component": "openstack-nova-api-docker",
+ "description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "distribution-scope": "public",
+ "io.k8s.description": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.k8s.display-name": "Red Hat OpenStack Platform 12.0 nova-api",
+ "io.openshift.tags": "rhosp osp openstack osp-12.0",
+ "kolla_version": "stable/pike",
+ "name": "rhosp12/openstack-nova-api",
+ "release": "20180124.1",
+ "summary": "Red Hat OpenStack Platform 12.0 nova-api",
+ "tripleo-common_version": "7.6.3-23-g4891cfe",
+ "url": "https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1",
+ "vcs-ref": "9b31243b7b448eb2fc3b6e2c96935b948f806e98",
+ "vcs-type": "git",
+ "vendor": "Red Hat, Inc.",
+ "version": "12.0",
+ "version-release": "12.0-20180124.1"
+ }
+ },
+ "created": "2018-01-25T00:37:48.268558Z",
+ "docker_version": "1.12.6",
+ "os": "linux",
+ "history": [
+ {
+ "comment": "Imported from -",
+ "created": "2017-11-21T16:47:27.755341705Z"
+ },
+ {
+ "author": "Red Hat, Inc.",
+ "created": "2017-11-21T16:49:37.292899Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/compose-rpms-1.repo'"
+ },
+ {
+ "created": "2018-01-24T21:40:32.494686Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'"
+ },
+ {
+ "created": "2018-01-24T22:00:57.807862Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'"
+ },
+ {
+ "created": "2018-01-24T23:08:25.300741Z",
+ "created_by": "/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'"
+ },
+ {
+ "created": "2018-01-25T00:37:48.268558Z",
+ "created_by": "/bin/sh -c #(nop) USER [nova]"
+ }
+ ],
+ "rootfs": {
+ "type": "layers",
+ "diff_ids": [
+ "sha256:e1d829eddb62dc49f1c56dbf8acd0c71299b3996115399de853a9d66d81b822f",
+ "sha256:02404b4d7e5d89b1383ca346b4462b199128aa4b238c5a2b2c186004ac148ba8",
+ "sha256:45fad80a4b1cec165c421eb570dec312d825bd8fac362e255028fa3f2169148d",
+ "sha256:7ddef8efd44586e54880ec4797458eac87b368544c438d7e7c63fbc0d9a7ae97",
+ "sha256:b56b16b6407ba1b86252e7e50f98f142cf6844fab42e4495d56ebb7ce559e2af",
+ "sha256:9bd63850e406167b4751f5050f6dc0ebd789bb5ef5e5c6c31ed062bda8c063e8"
+ ]
+ }
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema1-to-schema2.json b/internal/image/fixtures/schema1-to-schema2.json
new file mode 100644
index 0000000..9d6feee
--- /dev/null
+++ b/internal/image/fixtures/schema1-to-schema2.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": -1,
+ "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 74876245,
+ "digest": "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 1239,
+ "digest": "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 78339724,
+ "digest": "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 76857203,
+ "digest": "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 25923380,
+ "digest": "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 23511300,
+ "digest": "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema1.json b/internal/image/fixtures/schema1.json
new file mode 100644
index 0000000..d741149
--- /dev/null
+++ b/internal/image/fixtures/schema1.json
@@ -0,0 +1,62 @@
+{
+ "schemaVersion": 1,
+ "name": "rhosp12/openstack-nova-api",
+ "tag": "latest",
+ "architecture": "amd64",
+ "fsLayers": [
+ {
+ "blobSum": "sha256:e623934bca8d1a74f51014256445937714481e49343a31bda2bc5f534748184d"
+ },
+ {
+ "blobSum": "sha256:62e48e39dc5b30b75a97f05bccc66efbae6058b860ee20a5c9a184b9d5e25788"
+ },
+ {
+ "blobSum": "sha256:9e92df2aea7dc0baf5f1f8d509678d6a6306de27ad06513f8e218371938c07a6"
+ },
+ {
+ "blobSum": "sha256:f576d102e09b9eef0e305aaef705d2d43a11bebc3fd5810a761624bd5e11997e"
+ },
+ {
+ "blobSum": "sha256:4aa565ad8b7a87248163ce7dba1dd3894821aac97e846b932ff6b8ef9a8a508a"
+ },
+ {
+ "blobSum": "sha256:9cadd93b16ff2a0c51ac967ea2abfadfac50cfa3af8b5bf983d89b8f8647f3e4"
+ }
+ ],
+ "history": [
+ {
+ "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"9428cdea83ba\",\"Domainname\":\"\",\"User\":\"nova\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"container=oci\",\"KOLLA_BASE_DISTRO=rhel\",\"KOLLA_INSTALL_TYPE=binary\",\"KOLLA_INSTALL_METATYPE=rhos\",\"PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ \"],\"Cmd\":[\"kolla_start\"],\"Healthcheck\":{\"Test\":[\"CMD-SHELL\",\"/openstack/healthcheck\"]},\"ArgsEscaped\":true,\"Image\":\"3bf9afe371220b1eb1c57bec39b5a99ba976c36c92d964a1c014584f95f51e33\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"Kolla-SHA\":\"5.0.0-39-g6f1b947b\",\"architecture\":\"x86_64\",\"authoritative-source-url\":\"registry.access.redhat.com\",\"build-date\":\"2018-01-25T00:32:27.807261\",\"com.redhat.build-host\":\"ip-10-29-120-186.ec2.internal\",\"com.redhat.component\":\"openstack-nova-api-docker\",\"description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"distribution-scope\":\"public\",\"io.k8s.description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.k8s.display-name\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.openshift.tags\":\"rhosp osp openstack osp-12.0\",\"kolla_version\":\"stable/pike\",\"name\":\"rhosp12/openstack-nova-api\",\"release\":\"20180124.1\",\"summary\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"tripleo-common_version\":\"7.6.3-23-g4891cfe\",\"url\":\"https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1\",\"vcs-ref\":\"9b31243b7b448eb2fc3b6e2c96935b948f806e98\",\"vcs-type\":\"git\",\"vendor\":\"Red Hat, Inc.\",\"version\":\"12.0\",\"version-release\":\"12.0-20180124.1\"}},\"container_config\":{\"Hostname\":\"9428cdea83ba\",\"Domainname\":\"\",\"User\":\"nova\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"container=oci\",\"KOLLA_BASE_DISTRO=rhel\",\"KOLLA_INSTALL_TYPE=binary\",\"KOLLA_INSTALL_METATYPE=rhos\",\"PS1=$(tput bold)($(printenv KOLLA_SERVICE_NAME))$(tput sgr0)[$(id -un)@$(hostname -s) $(pwd)]$ \"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"USER [nova]\"],\"Healthcheck\":{\"Test\":[\"CMD-SHELL\",\"/openstack/healthcheck\"]},\"ArgsEscaped\":true,\"Image\":\"sha256:274ce4dcbeb09fa173a5d50203ae5cec28f456d1b8b59477b47a42bd74d068bf\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"Kolla-SHA\":\"5.0.0-39-g6f1b947b\",\"architecture\":\"x86_64\",\"authoritative-source-url\":\"registry.access.redhat.com\",\"build-date\":\"2018-01-25T00:32:27.807261\",\"com.redhat.build-host\":\"ip-10-29-120-186.ec2.internal\",\"com.redhat.component\":\"openstack-nova-api-docker\",\"description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"distribution-scope\":\"public\",\"io.k8s.description\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.k8s.display-name\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"io.openshift.tags\":\"rhosp osp openstack osp-12.0\",\"kolla_version\":\"stable/pike\",\"name\":\"rhosp12/openstack-nova-api\",\"release\":\"20180124.1\",\"summary\":\"Red Hat OpenStack Platform 12.0 nova-api\",\"tripleo-common_version\":\"7.6.3-23-g4891cfe\",\"url\":\"https://access.redhat.com/containers/#/registry.access.redhat.com/rhosp12/openstack-nova-api/images/12.0-20180124.1\",\"vcs-ref\":\"9b31243b7b448eb2fc3b6e2c96935b948f806e98\",\"vcs-type\":\"git\",\"vendor\":\"Red Hat, Inc.\",\"version\":\"12.0\",\"version-release\":\"12.0-20180124.1\"}},\"created\":\"2018-01-25T00:37:48.268558Z\",\"docker_version\":\"1.12.6\",\"id\":\"486cbbaf6c6f7d890f9368c86eda3f4ebe3ae982b75098037eb3c3cc6f0e0cdf\",\"os\":\"linux\",\"parent\":\"20d0c9c79f9fee83c4094993335b9b321112f13eef60ed9ec1599c7593dccf20\"}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"20d0c9c79f9fee83c4094993335b9b321112f13eef60ed9ec1599c7593dccf20\",\"parent\":\"47a1014db2116c312736e11adcc236fb77d0ad32457f959cbaec0c3fc9ab1caa\",\"created\":\"2018-01-24T23:08:25.300741Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'\"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"47a1014db2116c312736e11adcc236fb77d0ad32457f959cbaec0c3fc9ab1caa\",\"parent\":\"cec66cab6c92a5f7b50ef407b80b83840a0d089b9896257609fd01de3a595824\",\"created\":\"2018-01-24T22:00:57.807862Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'\"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"cec66cab6c92a5f7b50ef407b80b83840a0d089b9896257609fd01de3a595824\",\"parent\":\"0e7730eccb3d014b33147b745d771bc0e38a967fd932133a6f5325a3c84282e2\",\"created\":\"2018-01-24T21:40:32.494686Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/rhel-7.4.repo' '/etc/yum.repos.d/rhos-optools-12.0.repo' '/etc/yum.repos.d/rhos-12.0-container-yum-need_images.repo'\"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"0e7730eccb3d014b33147b745d771bc0e38a967fd932133a6f5325a3c84282e2\",\"parent\":\"3e49094c0233214ab73f8e5c204af8a14cfc6f0403384553c17fbac2e9d38345\",\"created\":\"2017-11-21T16:49:37.292899Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c rm -f '/etc/yum.repos.d/compose-rpms-1.repo'\"]},\"author\":\"Red Hat, Inc.\"}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"3e49094c0233214ab73f8e5c204af8a14cfc6f0403384553c17fbac2e9d38345\",\"comment\":\"Imported from -\",\"created\":\"2017-11-21T16:47:27.755341705Z\",\"container_config\":{\"Cmd\":[\"\"]}}"
+ }
+ ],
+ "signatures": [
+ {
+ "header": {
+ "jwk": {
+ "crv": "P-256",
+ "kid": "DB2X:GSG2:72H3:AE3R:KCMI:Y77E:W7TF:ERHK:V5HR:JJ2Y:YMS6:HFGJ",
+ "kty": "EC",
+ "x": "jyr9-xZBorSC9fhqNsmfU_Ud31wbaZ-bVGz0HmySvbQ",
+ "y": "vkE6qZCCvYRWjSUwgAOvibQx_s8FipYkAiHS0VnAFNs"
+ },
+ "alg": "ES256"
+ },
+ "signature": "jBBsnocfxw77LzmM_VeN6Nb031BtqPgx-DbppYOEnhZfGLRcyYwGUPW--3JrkeEX6AlEGzPI57R0tlu5bZvrnQ",
+ "protected": "eyJmb3JtYXRMZW5ndGgiOjY4MTMsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxOC0wMS0zMFQxOToyNToxMloifQ"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema2-all-media-types-to-oci1.json b/internal/image/fixtures/schema2-all-media-types-to-oci1.json
new file mode 100644
index 0000000..65fff4a
--- /dev/null
+++ b/internal/image/fixtures/schema2-all-media-types-to-oci1.json
@@ -0,0 +1,36 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 4670,
+ "digest": "sha256:f15ba60ec257ee2cf4fddfb9451bb86ba2668450e88d402f5ecc7ea6ce1b661a"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 152,
+ "digest": "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema2-all-media-types.json b/internal/image/fixtures/schema2-all-media-types.json
new file mode 100644
index 0000000..2c3d8c7
--- /dev/null
+++ b/internal/image/fixtures/schema2-all-media-types.json
@@ -0,0 +1,36 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": 4651,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 152,
+ "digest": "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema2-config.json b/internal/image/fixtures/schema2-config.json
new file mode 100644
index 0000000..f49230e
--- /dev/null
+++ b/internal/image/fixtures/schema2-config.json
@@ -0,0 +1 @@
+{"architecture":"amd64","config":{"Hostname":"383850eeb47b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","HTTPD_PREFIX=/usr/local/apache2","HTTPD_VERSION=2.4.23","HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"],"Cmd":["httpd-foreground"],"ArgsEscaped":true,"Image":"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd","Volumes":null,"WorkingDir":"/usr/local/apache2","Entrypoint":null,"OnBuild":[],"Labels":{}},"container":"8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69","container_config":{"Hostname":"383850eeb47b","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"ExposedPorts":{"80/tcp":{}},"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","HTTPD_PREFIX=/usr/local/apache2","HTTPD_VERSION=2.4.23","HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"httpd-foreground\"]"],"ArgsEscaped":true,"Image":"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd","Volumes":null,"WorkingDir":"/usr/local/apache2","Entrypoint":null,"OnBuild":[],"Labels":{}},"created":"2016-09-23T23:20:45.78976459Z","docker_version":"1.12.1","history":[{"created":"2016-09-23T18:08:50.537223822Z","created_by":"/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "},{"created":"2016-09-23T18:08:51.133779867Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/bash\"]","empty_layer":true},{"created":"2016-09-23T19:16:40.725768956Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2","empty_layer":true},{"created":"2016-09-23T19:16:41.037788416Z","created_by":"/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","empty_layer":true},{"created":"2016-09-23T19:16:41.990121202Z","created_by":"/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""},{"created":"2016-09-23T19:16:42.339911155Z","created_by":"/bin/sh -c #(nop) WORKDIR /usr/local/apache2","empty_layer":true},{"created":"2016-09-23T19:16:54.948461741Z","created_by":"/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"},{"created":"2016-09-23T19:16:55.321573403Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23","empty_layer":true},{"created":"2016-09-23T19:16:55.629947307Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f","empty_layer":true},{"created":"2016-09-23T23:19:03.705796801Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2","empty_layer":true},{"created":"2016-09-23T23:19:04.009782822Z","created_by":"/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc","empty_layer":true},{"created":"2016-09-23T23:20:44.585743332Z","created_by":"/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"},{"created":"2016-09-23T23:20:45.127455562Z","created_by":"/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "},{"created":"2016-09-23T23:20:45.453934921Z","created_by":"/bin/sh -c #(nop) EXPOSE 80/tcp","empty_layer":true},{"created":"2016-09-23T23:20:45.78976459Z","created_by":"/bin/sh -c #(nop) CMD [\"httpd-foreground\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab","sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c","sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56","sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9","sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b"]}} \ No newline at end of file
diff --git a/internal/image/fixtures/schema2-invalid-media-type.json b/internal/image/fixtures/schema2-invalid-media-type.json
new file mode 100644
index 0000000..d6b0691
--- /dev/null
+++ b/internal/image/fixtures/schema2-invalid-media-type.json
@@ -0,0 +1,36 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/octet-stream",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.zstd",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+ } \ No newline at end of file
diff --git a/internal/image/fixtures/schema2-to-oci1-config.json b/internal/image/fixtures/schema2-to-oci1-config.json
new file mode 100644
index 0000000..eb43d87
--- /dev/null
+++ b/internal/image/fixtures/schema2-to-oci1-config.json
@@ -0,0 +1,105 @@
+{
+ "architecture": "amd64",
+ "config": {
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Env": [
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc"
+ ],
+ "Cmd": [
+ "httpd-foreground"
+ ],
+ "ArgsEscaped": true,
+ "WorkingDir": "/usr/local/apache2"
+ },
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "history": [
+ {
+ "created": "2016-09-23T18:08:50.537223822Z",
+ "created_by": "/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / "
+ },
+ {
+ "created": "2016-09-23T18:08:51.133779867Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:40.725768956Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.037788416Z",
+ "created_by": "/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:41.990121202Z",
+ "created_by": "/bin/sh -c mkdir -p \"$HTTPD_PREFIX\" \t\u0026\u0026 chown www-data:www-data \"$HTTPD_PREFIX\""
+ },
+ {
+ "created": "2016-09-23T19:16:42.339911155Z",
+ "created_by": "/bin/sh -c #(nop) WORKDIR /usr/local/apache2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:54.948461741Z",
+ "created_by": "/bin/sh -c apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends \t\tlibapr1 \t\tlibaprutil1 \t\tlibaprutil1-ldap \t\tlibapr1-dev \t\tlibaprutil1-dev \t\tlibpcre++0 \t\tlibssl1.0.0 \t\u0026\u0026 rm -r /var/lib/apt/lists/*"
+ },
+ {
+ "created": "2016-09-23T19:16:55.321573403Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T19:16:55.629947307Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:03.705796801Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\u0026filename=httpd/httpd-2.4.23.tar.bz2",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:19:04.009782822Z",
+ "created_by": "/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:44.585743332Z",
+ "created_by": "/bin/sh -c set -x \t\u0026\u0026 buildDeps=' \t\tbzip2 \t\tca-certificates \t\tgcc \t\tlibpcre++-dev \t\tlibssl-dev \t\tmake \t\twget \t' \t\u0026\u0026 apt-get update \t\u0026\u0026 apt-get install -y --no-install-recommends $buildDeps \t\u0026\u0026 rm -r /var/lib/apt/lists/* \t\t\u0026\u0026 wget -O httpd.tar.bz2 \"$HTTPD_BZ2_URL\" \t\u0026\u0026 echo \"$HTTPD_SHA1 *httpd.tar.bz2\" | sha1sum -c - \t\u0026\u0026 wget -O httpd.tar.bz2.asc \"$HTTPD_ASC_URL\" \t\u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \t\u0026\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \t\u0026\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \t\u0026\u0026 rm -r \"$GNUPGHOME\" httpd.tar.bz2.asc \t\t\u0026\u0026 mkdir -p src \t\u0026\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \t\u0026\u0026 rm httpd.tar.bz2 \t\u0026\u0026 cd src \t\t\u0026\u0026 ./configure \t\t--prefix=\"$HTTPD_PREFIX\" \t\t--enable-mods-shared=reallyall \t\u0026\u0026 make -j\"$(nproc)\" \t\u0026\u0026 make install \t\t\u0026\u0026 cd .. \t\u0026\u0026 rm -r src \t\t\u0026\u0026 sed -ri \t\t-e 's!^(\\s*CustomLog)\\s+\\S+!\\1 /proc/self/fd/1!g' \t\t-e 's!^(\\s*ErrorLog)\\s+\\S+!\\1 /proc/self/fd/2!g' \t\t\"$HTTPD_PREFIX/conf/httpd.conf\" \t\t\u0026\u0026 apt-get purge -y --auto-remove $buildDeps"
+ },
+ {
+ "created": "2016-09-23T23:20:45.127455562Z",
+ "created_by": "/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ "
+ },
+ {
+ "created": "2016-09-23T23:20:45.453934921Z",
+ "created_by": "/bin/sh -c #(nop) EXPOSE 80/tcp",
+ "empty_layer": true
+ },
+ {
+ "created": "2016-09-23T23:20:45.78976459Z",
+ "created_by": "/bin/sh -c #(nop) CMD [\"httpd-foreground\"]",
+ "empty_layer": true
+ }
+ ],
+ "os": "linux",
+ "rootfs": {
+ "type": "layers",
+ "diff_ids": [
+ "sha256:142a601d97936307e75220c35dde0348971a9584c21e7cb42e1f7004005432ab",
+ "sha256:90fcc66ad3be9f1757f954b750deb37032f208428aa12599fcb02182b9065a9c",
+ "sha256:5a8624bb7e76d1e6829f9c64c43185e02bc07f97a2189eb048609a8914e72c56",
+ "sha256:d349ff6b3afc6a2800054768c82bfbf4289c9aa5da55c1290f802943dcd4d1e9",
+ "sha256:8c064bb1f60e84fa8cc6079b6d2e76e0423389fd6aeb7e497dfdae5e05b2b25b"
+ ]
+ }
+} \ No newline at end of file
diff --git a/internal/image/fixtures/schema2-to-oci1.json b/internal/image/fixtures/schema2-to-oci1.json
new file mode 100644
index 0000000..251e4e5
--- /dev/null
+++ b/internal/image/fixtures/schema2-to-oci1.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 4670,
+ "digest": "sha256:f15ba60ec257ee2cf4fddfb9451bb86ba2668450e88d402f5ecc7ea6ce1b661a"
+ },
+ "layers": [{
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ }, {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ }, {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ }, {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ }, {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }]
+}
diff --git a/internal/image/fixtures/schema2-to-schema1-by-docker.json b/internal/image/fixtures/schema2-to-schema1-by-docker.json
new file mode 100644
index 0000000..494450d
--- /dev/null
+++ b/internal/image/fixtures/schema2-to-schema1-by-docker.json
@@ -0,0 +1,116 @@
+{
+ "schemaVersion": 1,
+ "name": "library/httpd-copy",
+ "tag": "latest",
+ "architecture": "amd64",
+ "fsLayers": [
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ },
+ {
+ "blobSum": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ },
+ {
+ "blobSum": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ }
+ ],
+ "history": [
+ {
+ "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"383850eeb47b\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":{\"80/tcp\":{}},\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"HTTPD_PREFIX=/usr/local/apache2\",\"HTTPD_VERSION=2.4.23\",\"HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f\",\"HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\\u0026filename=httpd/httpd-2.4.23.tar.bz2\",\"HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc\"],\"Cmd\":[\"httpd-foreground\"],\"ArgsEscaped\":true,\"Image\":\"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd\",\"Volumes\":null,\"WorkingDir\":\"/usr/local/apache2\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{}},\"container\":\"8825acde1b009729807e4b70a65a89399dd8da8e53be9216b9aaabaff4339f69\",\"container_config\":{\"Hostname\":\"383850eeb47b\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":{\"80/tcp\":{}},\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\"HTTPD_PREFIX=/usr/local/apache2\",\"HTTPD_VERSION=2.4.23\",\"HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f\",\"HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\\u0026filename=httpd/httpd-2.4.23.tar.bz2\",\"HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"httpd-foreground\\\"]\"],\"ArgsEscaped\":true,\"Image\":\"sha256:4f83530449c67c1ed8fca72583c5b92fdf446010990028c362a381e55dd84afd\",\"Volumes\":null,\"WorkingDir\":\"/usr/local/apache2\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{}},\"created\":\"2016-09-23T23:20:45.78976459Z\",\"docker_version\":\"1.12.1\",\"id\":\"dca7323f9c839837493199d63263083d94f5eb1796d7bd04ca8374c4e9d3749a\",\"os\":\"linux\",\"parent\":\"1b750729af47c9a802c8d14b0d327d3ad5ecdce5ae773ac728a0263315b914f4\",\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"1b750729af47c9a802c8d14b0d327d3ad5ecdce5ae773ac728a0263315b914f4\",\"parent\":\"3ef2f186f8b0a2fd2d95f5a1f1cd213f5fb0a6e51b0a8dfbe2ec7003a788ff9a\",\"created\":\"2016-09-23T23:20:45.453934921Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) EXPOSE 80/tcp\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"3ef2f186f8b0a2fd2d95f5a1f1cd213f5fb0a6e51b0a8dfbe2ec7003a788ff9a\",\"parent\":\"dbbb5c772ba968f675ebdb1968a2fbcf3cf53c0c85ff4e3329619e3735c811e6\",\"created\":\"2016-09-23T23:20:45.127455562Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) COPY file:761e313354b918b6cd7ea99975a4f6b53ff5381ba689bab2984aec4dab597215 in /usr/local/bin/ \"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"dbbb5c772ba968f675ebdb1968a2fbcf3cf53c0c85ff4e3329619e3735c811e6\",\"parent\":\"d264ded964bb52f78c8905c9e6c5f2b8526ef33f371981f0651f3fb0164ad4a7\",\"created\":\"2016-09-23T23:20:44.585743332Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c set -x \\t\\u0026\\u0026 buildDeps=' \\t\\tbzip2 \\t\\tca-certificates \\t\\tgcc \\t\\tlibpcre++-dev \\t\\tlibssl-dev \\t\\tmake \\t\\twget \\t' \\t\\u0026\\u0026 apt-get update \\t\\u0026\\u0026 apt-get install -y --no-install-recommends $buildDeps \\t\\u0026\\u0026 rm -r /var/lib/apt/lists/* \\t\\t\\u0026\\u0026 wget -O httpd.tar.bz2 \\\"$HTTPD_BZ2_URL\\\" \\t\\u0026\\u0026 echo \\\"$HTTPD_SHA1 *httpd.tar.bz2\\\" | sha1sum -c - \\t\\u0026\\u0026 wget -O httpd.tar.bz2.asc \\\"$HTTPD_ASC_URL\\\" \\t\\u0026\\u0026 export GNUPGHOME=\\\"$(mktemp -d)\\\" \\t\\u0026\\u0026 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys A93D62ECC3C8EA12DB220EC934EA76E6791485A8 \\t\\u0026\\u0026 gpg --batch --verify httpd.tar.bz2.asc httpd.tar.bz2 \\t\\u0026\\u0026 rm -r \\\"$GNUPGHOME\\\" httpd.tar.bz2.asc \\t\\t\\u0026\\u0026 mkdir -p src \\t\\u0026\\u0026 tar -xvf httpd.tar.bz2 -C src --strip-components=1 \\t\\u0026\\u0026 rm httpd.tar.bz2 \\t\\u0026\\u0026 cd src \\t\\t\\u0026\\u0026 ./configure \\t\\t--prefix=\\\"$HTTPD_PREFIX\\\" \\t\\t--enable-mods-shared=reallyall \\t\\u0026\\u0026 make -j\\\"$(nproc)\\\" \\t\\u0026\\u0026 make install \\t\\t\\u0026\\u0026 cd .. \\t\\u0026\\u0026 rm -r src \\t\\t\\u0026\\u0026 sed -ri \\t\\t-e 's!^(\\\\s*CustomLog)\\\\s+\\\\S+!\\\\1 /proc/self/fd/1!g' \\t\\t-e 's!^(\\\\s*ErrorLog)\\\\s+\\\\S+!\\\\1 /proc/self/fd/2!g' \\t\\t\\\"$HTTPD_PREFIX/conf/httpd.conf\\\" \\t\\t\\u0026\\u0026 apt-get purge -y --auto-remove $buildDeps\"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"d264ded964bb52f78c8905c9e6c5f2b8526ef33f371981f0651f3fb0164ad4a7\",\"parent\":\"fd6f8d569a8a6d2a95f797494ab3cee7a47693dde647210b236a141f76b5c5fd\",\"created\":\"2016-09-23T23:19:04.009782822Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"fd6f8d569a8a6d2a95f797494ab3cee7a47693dde647210b236a141f76b5c5fd\",\"parent\":\"5e2578d171daa47c0eeb55e592b4e3bd28a0946a75baed58e4d4dd315c5d5780\",\"created\":\"2016-09-23T23:19:03.705796801Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download\\u0026filename=httpd/httpd-2.4.23.tar.bz2\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"5e2578d171daa47c0eeb55e592b4e3bd28a0946a75baed58e4d4dd315c5d5780\",\"parent\":\"1912159ee5bea8d7fde49b85012f90c47bceb3f09e4082b112b1f06a3f339c53\",\"created\":\"2016-09-23T19:16:55.629947307Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"1912159ee5bea8d7fde49b85012f90c47bceb3f09e4082b112b1f06a3f339c53\",\"parent\":\"3bfb089ca9d4bb73a9016e44a2c6f908b701f97704433305c419f75e8559d8a2\",\"created\":\"2016-09-23T19:16:55.321573403Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_VERSION=2.4.23\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"3bfb089ca9d4bb73a9016e44a2c6f908b701f97704433305c419f75e8559d8a2\",\"parent\":\"ae1ece73de4d0365c8b8ab45ba0bf6b1efa4213c16a4903b89341b704d101c3c\",\"created\":\"2016-09-23T19:16:54.948461741Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c apt-get update \\t\\u0026\\u0026 apt-get install -y --no-install-recommends \\t\\tlibapr1 \\t\\tlibaprutil1 \\t\\tlibaprutil1-ldap \\t\\tlibapr1-dev \\t\\tlibaprutil1-dev \\t\\tlibpcre++0 \\t\\tlibssl1.0.0 \\t\\u0026\\u0026 rm -r /var/lib/apt/lists/*\"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"ae1ece73de4d0365c8b8ab45ba0bf6b1efa4213c16a4903b89341b704d101c3c\",\"parent\":\"bffbcb416f40e0bd3ebae202403587bfd41829cd1e0d538b66f29adce40c6408\",\"created\":\"2016-09-23T19:16:42.339911155Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) WORKDIR /usr/local/apache2\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"bffbcb416f40e0bd3ebae202403587bfd41829cd1e0d538b66f29adce40c6408\",\"parent\":\"7b27731a3363efcb6b0520962d544471745aae15664920dffe690b4fdb410d80\",\"created\":\"2016-09-23T19:16:41.990121202Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c mkdir -p \\\"$HTTPD_PREFIX\\\" \\t\\u0026\\u0026 chown www-data:www-data \\\"$HTTPD_PREFIX\\\"\"]}}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"7b27731a3363efcb6b0520962d544471745aae15664920dffe690b4fdb410d80\",\"parent\":\"57a0a421f1acbc1fe6b88b32d3d1c3c0388ff1958b97f95dd0e3a599b810499b\",\"created\":\"2016-09-23T19:16:41.037788416Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"57a0a421f1acbc1fe6b88b32d3d1c3c0388ff1958b97f95dd0e3a599b810499b\",\"parent\":\"faeaf6fdfdcbb18d68c12db9683a02428bab83962a493de88b4c7b1ec941db8f\",\"created\":\"2016-09-23T19:16:40.725768956Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ENV HTTPD_PREFIX=/usr/local/apache2\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"faeaf6fdfdcbb18d68c12db9683a02428bab83962a493de88b4c7b1ec941db8f\",\"parent\":\"d0c4f1eb7dc8f4dae2b45fe5c0cf4cfc70e5be85d933f5f5f4deb59f134fb520\",\"created\":\"2016-09-23T18:08:51.133779867Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) CMD [\\\"/bin/bash\\\"]\"]},\"throwaway\":true}"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"d0c4f1eb7dc8f4dae2b45fe5c0cf4cfc70e5be85d933f5f5f4deb59f134fb520\",\"created\":\"2016-09-23T18:08:50.537223822Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:c6c23585ab140b0b320d4e99bc1b0eb544c9e96c24d90fec5e069a6d57d335ca in / \"]}}"
+ }
+ ],
+ "signatures": [
+ {
+ "header": {
+ "jwk": {
+ "crv": "P-256",
+ "kid": "6QVR:5NTY:VIHC:W6IU:XYIN:CTKT:OG5R:XEEG:Z6XJ:2623:YCBP:36MA",
+ "kty": "EC",
+ "x": "NAGHj6-IdNonuFoxlqJnNMjcrCCE1CBoq2r_1NDci68",
+ "y": "Kocqgj_Ey5J-wLXTjkuqLC-HjciAnWxsBEziAOTvSPc"
+ },
+ "alg": "ES256"
+ },
+ "signature": "2MN5k06i8xkJhD5ay4yxAFK7tsZk58UznAZONxDplvQ5lZwbRS162OeBDjCb0Hk0IDyrLXtAfBDlY2Gzf6jrpw",
+ "protected": "eyJmb3JtYXRMZW5ndGgiOjEwODk1LCJmb3JtYXRUYWlsIjoiQ24wIiwidGltZSI6IjIwMTYtMTAtMTRUMTY6MTI6MDlaIn0"
+ }
+ ]
+}
diff --git a/internal/image/fixtures/schema2.json b/internal/image/fixtures/schema2.json
new file mode 100644
index 0000000..8df4c0d
--- /dev/null
+++ b/internal/image/fixtures/schema2.json
@@ -0,0 +1,36 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/octet-stream",
+ "size": 5940,
+ "digest": "sha256:9ca4bda0a6b3727a6ffcc43e981cad0f24e2ec79d338f6ba325b4dfd0756fb8f"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 51354364,
+ "digest": "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 150,
+ "digest": "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 11739507,
+ "digest": "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 8841833,
+ "digest": "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 291,
+ "digest": "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/image/manifest.go b/internal/image/manifest.go
new file mode 100644
index 0000000..75e472a
--- /dev/null
+++ b/internal/image/manifest.go
@@ -0,0 +1,121 @@
+package image
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/types"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// genericManifest is an interface for parsing, modifying image manifests and related data.
+// The public methods are related to types.Image so that embedding a genericManifest implements most of it,
+// but there are also public methods that are only visible by packages that can import c/image/internal/image.
+type genericManifest interface {
+ serialize() ([]byte, error)
+ manifestMIMEType() string
+ // ConfigInfo returns a complete BlobInfo for the separate config object, or a BlobInfo{Digest:""} if there isn't a separate object.
+ // Note that the config object may not exist in the underlying storage in the return value of UpdatedImage! Use ConfigBlob() below.
+ ConfigInfo() types.BlobInfo
+ // ConfigBlob returns the blob described by ConfigInfo, iff ConfigInfo().Digest != ""; nil otherwise.
+ // The result is cached; it is OK to call this however often you need.
+ ConfigBlob(context.Context) ([]byte, error)
+ // OCIConfig returns the image configuration as per OCI v1 image-spec. Information about
+ // layers in the resulting configuration isn't guaranteed to be returned to due how
+ // old image manifests work (docker v2s1 especially).
+ OCIConfig(context.Context) (*imgspecv1.Image, error)
+ // LayerInfos returns a list of BlobInfos of layers referenced by this image, in order (the root layer first, and then successive layered layers).
+ // The Digest field is guaranteed to be provided; Size may be -1.
+ // WARNING: The list may contain duplicates, and they are semantically relevant.
+ LayerInfos() []types.BlobInfo
+ // EmbeddedDockerReferenceConflicts whether a Docker reference embedded in the manifest, if any, conflicts with destination ref.
+ // It returns false if the manifest does not embed a Docker reference.
+ // (This embedding unfortunately happens for Docker schema1, please do not add support for this in any new formats.)
+ EmbeddedDockerReferenceConflicts(ref reference.Named) bool
+ // Inspect returns various information for (skopeo inspect) parsed from the manifest and configuration.
+ Inspect(context.Context) (*types.ImageInspectInfo, error)
+ // UpdatedImageNeedsLayerDiffIDs returns true iff UpdatedImage(options) needs InformationOnly.LayerDiffIDs.
+ // This is a horribly specific interface, but computing InformationOnly.LayerDiffIDs can be very expensive to compute
+ // (most importantly it forces us to download the full layers even if they are already present at the destination).
+ UpdatedImageNeedsLayerDiffIDs(options types.ManifestUpdateOptions) bool
+ // UpdatedImage returns a types.Image modified according to options.
+ // This does not change the state of the original Image object.
+ UpdatedImage(ctx context.Context, options types.ManifestUpdateOptions) (types.Image, error)
+ // SupportsEncryption returns if encryption is supported for the manifest type
+ //
+ // Deprecated: Initially used to determine if a manifest can be copied from a source manifest type since
+ // the process of updating a manifest between different manifest types was to update then convert.
+ // This resulted in some fields in the update being lost. This has been fixed by: https://github.com/containers/image/pull/836
+ SupportsEncryption(ctx context.Context) bool
+
+ // The following methods are not a part of types.Image:
+ // ===
+
+ // CanChangeLayerCompression returns true if we can compress/decompress layers with mimeType in the current image
+ // (and the code can handle that).
+ // NOTE: Even if this returns true, the relevant format might not accept all compression algorithms; the set of accepted
+ // algorithms depends not on the current format, but possibly on the target of a conversion (if UpdatedImage converts
+ // to a different manifest format).
+ CanChangeLayerCompression(mimeType string) bool
+}
+
+// manifestInstanceFromBlob returns a genericManifest implementation for (manblob, mt) in src.
+// If manblob is a manifest list, it implicitly chooses an appropriate image from the list.
+func manifestInstanceFromBlob(ctx context.Context, sys *types.SystemContext, src types.ImageSource, manblob []byte, mt string) (genericManifest, error) {
+ switch manifest.NormalizedMIMEType(mt) {
+ case manifest.DockerV2Schema1MediaType, manifest.DockerV2Schema1SignedMediaType:
+ return manifestSchema1FromManifest(manblob)
+ case imgspecv1.MediaTypeImageManifest:
+ return manifestOCI1FromManifest(src, manblob)
+ case manifest.DockerV2Schema2MediaType:
+ return manifestSchema2FromManifest(src, manblob)
+ case manifest.DockerV2ListMediaType:
+ return manifestSchema2FromManifestList(ctx, sys, src, manblob)
+ case imgspecv1.MediaTypeImageIndex:
+ return manifestOCI1FromImageIndex(ctx, sys, src, manblob)
+ default: // Note that this may not be reachable, manifest.NormalizedMIMEType has a default for unknown values.
+ return nil, fmt.Errorf("Unimplemented manifest MIME type %s", mt)
+ }
+}
+
+// manifestLayerInfosToBlobInfos extracts a []types.BlobInfo from a []manifest.LayerInfo.
+func manifestLayerInfosToBlobInfos(layers []manifest.LayerInfo) []types.BlobInfo {
+ blobs := make([]types.BlobInfo, len(layers))
+ for i, layer := range layers {
+ blobs[i] = layer.BlobInfo
+ }
+ return blobs
+}
+
+// manifestConvertFn (a method of genericManifest object) returns a genericManifest implementation
+// converted to a specific manifest MIME type.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original genericManifest object.
+type manifestConvertFn func(ctx context.Context, options *types.ManifestUpdateOptions) (genericManifest, error)
+
+// convertManifestIfRequiredWithUpdate will run conversion functions of a manifest if
+// required and re-apply the options to the converted type.
+// It returns (nil, nil) if no conversion was requested.
+func convertManifestIfRequiredWithUpdate(ctx context.Context, options types.ManifestUpdateOptions, converters map[string]manifestConvertFn) (types.Image, error) {
+ if options.ManifestMIMEType == "" {
+ return nil, nil
+ }
+
+ converter, ok := converters[options.ManifestMIMEType]
+ if !ok {
+ return nil, fmt.Errorf("Unsupported conversion type: %v", options.ManifestMIMEType)
+ }
+
+ optionsCopy := options
+ convertedManifest, err := converter(ctx, &optionsCopy)
+ if err != nil {
+ return nil, err
+ }
+ convertedImage := memoryImageFromManifest(convertedManifest)
+
+ optionsCopy.ManifestMIMEType = ""
+ return convertedImage.UpdatedImage(ctx, optionsCopy)
+}
diff --git a/internal/image/manifest_test.go b/internal/image/manifest_test.go
new file mode 100644
index 0000000..64ad130
--- /dev/null
+++ b/internal/image/manifest_test.go
@@ -0,0 +1,71 @@
+package image
+
+import (
+ "testing"
+
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/types"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManifestLayerInfosToBlobInfos(t *testing.T) {
+ blobs := manifestLayerInfosToBlobInfos([]manifest.LayerInfo{})
+ assert.Equal(t, []types.BlobInfo{}, blobs)
+
+ blobs = manifestLayerInfosToBlobInfos([]manifest.LayerInfo{
+ {
+ BlobInfo: types.BlobInfo{
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
+ Size: 32,
+ },
+ EmptyLayer: true,
+ },
+ {
+ BlobInfo: types.BlobInfo{
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ },
+ EmptyLayer: false,
+ },
+ {
+ BlobInfo: types.BlobInfo{
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ },
+ EmptyLayer: false,
+ },
+ {
+ BlobInfo: types.BlobInfo{
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
+ Size: 32,
+ },
+ EmptyLayer: true,
+ },
+ })
+ assert.Equal(t, []types.BlobInfo{
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
+ Size: 32,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ },
+ {
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ Digest: "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
+ Size: 32,
+ },
+ }, blobs)
+}
diff --git a/internal/image/memory.go b/internal/image/memory.go
new file mode 100644
index 0000000..e22c7aa
--- /dev/null
+++ b/internal/image/memory.go
@@ -0,0 +1,64 @@
+package image
+
+import (
+ "context"
+ "errors"
+
+ "github.com/containers/image/v5/types"
+)
+
+// memoryImage is a mostly-implementation of types.Image assembled from data
+// created in memory, used primarily as a return value of types.Image.UpdatedImage
+// as a way to carry various structured information in a type-safe and easy-to-use way.
+// Note that this _only_ carries the immediate metadata; it is _not_ a stand-alone
+// collection of all related information, e.g. there is no way to get layer blobs
+// from a memoryImage.
+type memoryImage struct {
+ genericManifest
+ serializedManifest []byte // A private cache for Manifest()
+}
+
+func memoryImageFromManifest(m genericManifest) types.Image {
+ return &memoryImage{
+ genericManifest: m,
+ serializedManifest: nil,
+ }
+}
+
+// Reference returns the reference used to set up this source, _as specified by the user_
+// (not as the image itself, or its underlying storage, claims). This can be used e.g. to determine which public keys are trusted for this image.
+func (i *memoryImage) Reference() types.ImageReference {
+ // It would really be inappropriate to return the ImageReference of the image this was based on.
+ return nil
+}
+
+// Size returns the size of the image as stored, if known, or -1 if not.
+func (i *memoryImage) Size() (int64, error) {
+ return -1, nil
+}
+
+// Manifest is like ImageSource.GetManifest, but the result is cached; it is OK to call this however often you need.
+func (i *memoryImage) Manifest(ctx context.Context) ([]byte, string, error) {
+ if i.serializedManifest == nil {
+ m, err := i.genericManifest.serialize()
+ if err != nil {
+ return nil, "", err
+ }
+ i.serializedManifest = m
+ }
+ return i.serializedManifest, i.genericManifest.manifestMIMEType(), nil
+}
+
+// Signatures is like ImageSource.GetSignatures, but the result is cached; it is OK to call this however often you need.
+func (i *memoryImage) Signatures(ctx context.Context) ([][]byte, error) {
+ // Modifying an image invalidates signatures; a caller asking the updated image for signatures
+ // is probably confused.
+ return nil, errors.New("Internal error: Image.Signatures() is not supported for images modified in memory")
+}
+
+// LayerInfosForCopy returns an updated set of layer blob information which may not match the manifest.
+// The Digest field is guaranteed to be provided; Size may be -1.
+// WARNING: The list may contain duplicates, and they are semantically relevant.
+func (i *memoryImage) LayerInfosForCopy(ctx context.Context) ([]types.BlobInfo, error) {
+ return nil, nil
+}
diff --git a/internal/image/oci.go b/internal/image/oci.go
new file mode 100644
index 0000000..df0e8e4
--- /dev/null
+++ b/internal/image/oci.go
@@ -0,0 +1,336 @@
+package image
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/iolimits"
+ internalManifest "github.com/containers/image/v5/internal/manifest"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/pkg/blobinfocache/none"
+ "github.com/containers/image/v5/types"
+ ociencspec "github.com/containers/ocicrypt/spec"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "golang.org/x/exp/slices"
+)
+
+type manifestOCI1 struct {
+ src types.ImageSource // May be nil if configBlob is not nil
+ configBlob []byte // If set, corresponds to contents of m.Config.
+ m *manifest.OCI1
+}
+
+func manifestOCI1FromManifest(src types.ImageSource, manifestBlob []byte) (genericManifest, error) {
+ m, err := manifest.OCI1FromManifest(manifestBlob)
+ if err != nil {
+ return nil, err
+ }
+ return &manifestOCI1{
+ src: src,
+ m: m,
+ }, nil
+}
+
+// manifestOCI1FromComponents builds a new manifestOCI1 from the supplied data:
+func manifestOCI1FromComponents(config imgspecv1.Descriptor, src types.ImageSource, configBlob []byte, layers []imgspecv1.Descriptor) genericManifest {
+ return &manifestOCI1{
+ src: src,
+ configBlob: configBlob,
+ m: manifest.OCI1FromComponents(config, layers),
+ }
+}
+
+func (m *manifestOCI1) serialize() ([]byte, error) {
+ return m.m.Serialize()
+}
+
+func (m *manifestOCI1) manifestMIMEType() string {
+ return imgspecv1.MediaTypeImageManifest
+}
+
+// ConfigInfo returns a complete BlobInfo for the separate config object, or a BlobInfo{Digest:""} if there isn't a separate object.
+// Note that the config object may not exist in the underlying storage in the return value of UpdatedImage! Use ConfigBlob() below.
+func (m *manifestOCI1) ConfigInfo() types.BlobInfo {
+ return m.m.ConfigInfo()
+}
+
+// ConfigBlob returns the blob described by ConfigInfo, iff ConfigInfo().Digest != ""; nil otherwise.
+// The result is cached; it is OK to call this however often you need.
+func (m *manifestOCI1) ConfigBlob(ctx context.Context) ([]byte, error) {
+ if m.configBlob == nil {
+ if m.src == nil {
+ return nil, errors.New("Internal error: neither src nor configBlob set in manifestOCI1")
+ }
+ stream, _, err := m.src.GetBlob(ctx, manifest.BlobInfoFromOCI1Descriptor(m.m.Config), none.NoCache)
+ if err != nil {
+ return nil, err
+ }
+ defer stream.Close()
+ blob, err := iolimits.ReadAtMost(stream, iolimits.MaxConfigBodySize)
+ if err != nil {
+ return nil, err
+ }
+ computedDigest := digest.FromBytes(blob)
+ if computedDigest != m.m.Config.Digest {
+ return nil, fmt.Errorf("Download config.json digest %s does not match expected %s", computedDigest, m.m.Config.Digest)
+ }
+ m.configBlob = blob
+ }
+ return m.configBlob, nil
+}
+
+// OCIConfig returns the image configuration as per OCI v1 image-spec. Information about
+// layers in the resulting configuration isn't guaranteed to be returned to due how
+// old image manifests work (docker v2s1 especially).
+func (m *manifestOCI1) OCIConfig(ctx context.Context) (*imgspecv1.Image, error) {
+ if m.m.Config.MediaType != imgspecv1.MediaTypeImageConfig {
+ return nil, internalManifest.NewNonImageArtifactError(&m.m.Manifest)
+ }
+
+ cb, err := m.ConfigBlob(ctx)
+ if err != nil {
+ return nil, err
+ }
+ configOCI := &imgspecv1.Image{}
+ if err := json.Unmarshal(cb, configOCI); err != nil {
+ return nil, err
+ }
+ return configOCI, nil
+}
+
+// LayerInfos returns a list of BlobInfos of layers referenced by this image, in order (the root layer first, and then successive layered layers).
+// The Digest field is guaranteed to be provided; Size may be -1.
+// WARNING: The list may contain duplicates, and they are semantically relevant.
+func (m *manifestOCI1) LayerInfos() []types.BlobInfo {
+ return manifestLayerInfosToBlobInfos(m.m.LayerInfos())
+}
+
+// EmbeddedDockerReferenceConflicts whether a Docker reference embedded in the manifest, if any, conflicts with destination ref.
+// It returns false if the manifest does not embed a Docker reference.
+// (This embedding unfortunately happens for Docker schema1, please do not add support for this in any new formats.)
+func (m *manifestOCI1) EmbeddedDockerReferenceConflicts(ref reference.Named) bool {
+ return false
+}
+
+// Inspect returns various information for (skopeo inspect) parsed from the manifest and configuration.
+func (m *manifestOCI1) Inspect(ctx context.Context) (*types.ImageInspectInfo, error) {
+ getter := func(info types.BlobInfo) ([]byte, error) {
+ if info.Digest != m.ConfigInfo().Digest {
+ // Shouldn't ever happen
+ return nil, errors.New("asked for a different config blob")
+ }
+ config, err := m.ConfigBlob(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return config, nil
+ }
+ return m.m.Inspect(getter)
+}
+
+// UpdatedImageNeedsLayerDiffIDs returns true iff UpdatedImage(options) needs InformationOnly.LayerDiffIDs.
+// This is a horribly specific interface, but computing InformationOnly.LayerDiffIDs can be very expensive to compute
+// (most importantly it forces us to download the full layers even if they are already present at the destination).
+func (m *manifestOCI1) UpdatedImageNeedsLayerDiffIDs(options types.ManifestUpdateOptions) bool {
+ return false
+}
+
+// UpdatedImage returns a types.Image modified according to options.
+// This does not change the state of the original Image object.
+// The returned error will be a manifest.ManifestLayerCompressionIncompatibilityError
+// if the combination of CompressionOperation and CompressionAlgorithm specified
+// in one or more options.LayerInfos items indicates that a layer is compressed using
+// an algorithm that is not allowed in OCI.
+func (m *manifestOCI1) UpdatedImage(ctx context.Context, options types.ManifestUpdateOptions) (types.Image, error) {
+ copy := manifestOCI1{ // NOTE: This is not a deep copy, it still shares slices etc.
+ src: m.src,
+ configBlob: m.configBlob,
+ m: manifest.OCI1Clone(m.m),
+ }
+
+ converted, err := convertManifestIfRequiredWithUpdate(ctx, options, map[string]manifestConvertFn{
+ manifest.DockerV2Schema2MediaType: copy.convertToManifestSchema2Generic,
+ manifest.DockerV2Schema1MediaType: copy.convertToManifestSchema1,
+ manifest.DockerV2Schema1SignedMediaType: copy.convertToManifestSchema1,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if converted != nil {
+ return converted, nil
+ }
+
+ // No conversion required, update manifest
+ if options.LayerInfos != nil {
+ if err := copy.m.UpdateLayerInfos(options.LayerInfos); err != nil {
+ return nil, err
+ }
+ }
+ // Ignore options.EmbeddedDockerReference: it may be set when converting from schema1, but we really don't care.
+
+ return memoryImageFromManifest(&copy), nil
+}
+
+func schema2DescriptorFromOCI1Descriptor(d imgspecv1.Descriptor) manifest.Schema2Descriptor {
+ return manifest.Schema2Descriptor{
+ MediaType: d.MediaType,
+ Size: d.Size,
+ Digest: d.Digest,
+ URLs: d.URLs,
+ }
+}
+
+// convertToManifestSchema2Generic returns a genericManifest implementation converted to manifest.DockerV2Schema2MediaType.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestSchema1 object.
+//
+// We need this function just because a function returning an implementation of the genericManifest
+// interface is not automatically assignable to a function type returning the genericManifest interface
+func (m *manifestOCI1) convertToManifestSchema2Generic(ctx context.Context, options *types.ManifestUpdateOptions) (genericManifest, error) {
+ return m.convertToManifestSchema2(ctx, options)
+}
+
+// layerEditsOfOCIOnlyFeatures checks if options requires some layer edits to be done before converting to a Docker format.
+// If not, it returns (nil, nil).
+// If decryption is required, it returns a set of edits to provide to OCI1.UpdateLayerInfos,
+// and edits *options to not try decryption again.
+func (m *manifestOCI1) layerEditsOfOCIOnlyFeatures(options *types.ManifestUpdateOptions) ([]types.BlobInfo, error) {
+ if options == nil || options.LayerInfos == nil {
+ return nil, nil
+ }
+
+ originalInfos := m.LayerInfos()
+ if len(originalInfos) != len(options.LayerInfos) {
+ return nil, fmt.Errorf("preparing to decrypt before conversion: %d layers vs. %d layer edits", len(originalInfos), len(options.LayerInfos))
+ }
+
+ ociOnlyEdits := slices.Clone(originalInfos) // Start with a full copy so that we don't forget to copy anything: use the current data in full unless we intentionally deviate.
+ laterEdits := slices.Clone(options.LayerInfos)
+ needsOCIOnlyEdits := false
+ for i, edit := range options.LayerInfos {
+ // Unless determined otherwise, don't do any compression-related MIME type conversions. m.LayerInfos() should not set these edit instructions, but be explicit.
+ ociOnlyEdits[i].CompressionOperation = types.PreserveOriginal
+ ociOnlyEdits[i].CompressionAlgorithm = nil
+
+ if edit.CryptoOperation == types.Decrypt {
+ needsOCIOnlyEdits = true // Encrypted types must be removed before conversion because they can’t be represented in Docker schemas
+ ociOnlyEdits[i].CryptoOperation = types.Decrypt
+ laterEdits[i].CryptoOperation = types.PreserveOriginalCrypto // Don't try to decrypt in a schema[12] manifest later, that would fail.
+ }
+
+ if originalInfos[i].MediaType == imgspecv1.MediaTypeImageLayerZstd ||
+ originalInfos[i].MediaType == imgspecv1.MediaTypeImageLayerNonDistributableZstd { //nolint:staticcheck // NonDistributable layers are deprecated, but we want to continue to support manipulating pre-existing images.
+ needsOCIOnlyEdits = true // Zstd MIME types must be removed before conversion because they can’t be represented in Docker schemas.
+ ociOnlyEdits[i].CompressionOperation = edit.CompressionOperation
+ ociOnlyEdits[i].CompressionAlgorithm = edit.CompressionAlgorithm
+ laterEdits[i].CompressionOperation = types.PreserveOriginal
+ laterEdits[i].CompressionAlgorithm = nil
+ }
+ }
+ if !needsOCIOnlyEdits {
+ return nil, nil
+ }
+
+ options.LayerInfos = laterEdits
+ return ociOnlyEdits, nil
+}
+
+// convertToManifestSchema2 returns a genericManifest implementation converted to manifest.DockerV2Schema2MediaType.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestOCI1 object.
+func (m *manifestOCI1) convertToManifestSchema2(_ context.Context, options *types.ManifestUpdateOptions) (*manifestSchema2, error) {
+ if m.m.Config.MediaType != imgspecv1.MediaTypeImageConfig {
+ return nil, internalManifest.NewNonImageArtifactError(&m.m.Manifest)
+ }
+
+ // Mostly we first make a format conversion, and _afterwards_ do layer edits. But first we need to do the layer edits
+ // which remove OCI-specific features, because trying to convert those layers would fail.
+ // So, do the layer updates for decryption, and for conversions from Zstd.
+ ociManifest := m.m
+ ociOnlyEdits, err := m.layerEditsOfOCIOnlyFeatures(options)
+ if err != nil {
+ return nil, err
+ }
+ if ociOnlyEdits != nil {
+ ociManifest = manifest.OCI1Clone(ociManifest)
+ if err := ociManifest.UpdateLayerInfos(ociOnlyEdits); err != nil {
+ return nil, err
+ }
+ }
+
+ // Create a copy of the descriptor.
+ config := schema2DescriptorFromOCI1Descriptor(ociManifest.Config)
+
+ // Above, we have already checked that this manifest refers to an image, not an OCI artifact,
+ // so the only difference between OCI and DockerSchema2 is the mediatypes. The
+ // media type of the manifest is handled by manifestSchema2FromComponents.
+ config.MediaType = manifest.DockerV2Schema2ConfigMediaType
+
+ layers := make([]manifest.Schema2Descriptor, len(ociManifest.Layers))
+ for idx := range layers {
+ layers[idx] = schema2DescriptorFromOCI1Descriptor(ociManifest.Layers[idx])
+ switch layers[idx].MediaType {
+ case imgspecv1.MediaTypeImageLayerNonDistributable: //nolint:staticcheck // NonDistributable layers are deprecated, but we want to continue to support manipulating pre-existing images.
+ layers[idx].MediaType = manifest.DockerV2Schema2ForeignLayerMediaType
+ case imgspecv1.MediaTypeImageLayerNonDistributableGzip: //nolint:staticcheck // NonDistributable layers are deprecated, but we want to continue to support manipulating pre-existing images.
+ layers[idx].MediaType = manifest.DockerV2Schema2ForeignLayerMediaTypeGzip
+ case imgspecv1.MediaTypeImageLayerNonDistributableZstd: //nolint:staticcheck // NonDistributable layers are deprecated, but we want to continue to support manipulating pre-existing images.
+ return nil, fmt.Errorf("Error during manifest conversion: %q: zstd compression is not supported for docker images", layers[idx].MediaType)
+ case imgspecv1.MediaTypeImageLayer:
+ layers[idx].MediaType = manifest.DockerV2SchemaLayerMediaTypeUncompressed
+ case imgspecv1.MediaTypeImageLayerGzip:
+ layers[idx].MediaType = manifest.DockerV2Schema2LayerMediaType
+ case imgspecv1.MediaTypeImageLayerZstd:
+ return nil, fmt.Errorf("Error during manifest conversion: %q: zstd compression is not supported for docker images", layers[idx].MediaType)
+ case ociencspec.MediaTypeLayerEnc, ociencspec.MediaTypeLayerGzipEnc, ociencspec.MediaTypeLayerZstdEnc,
+ ociencspec.MediaTypeLayerNonDistributableEnc, ociencspec.MediaTypeLayerNonDistributableGzipEnc, ociencspec.MediaTypeLayerNonDistributableZstdEnc:
+ return nil, fmt.Errorf("during manifest conversion: encrypted layers (%q) are not supported in docker images", layers[idx].MediaType)
+ default:
+ return nil, fmt.Errorf("Unknown media type during manifest conversion: %q", layers[idx].MediaType)
+ }
+ }
+
+ // Rather than copying the ConfigBlob now, we just pass m.src to the
+ // translated manifest, since the only difference is the mediatype of
+ // descriptors there is no change to any blob stored in m.src.
+ return manifestSchema2FromComponents(config, m.src, nil, layers), nil
+}
+
+// convertToManifestSchema1 returns a genericManifest implementation converted to manifest.DockerV2Schema1{Signed,}MediaType.
+// It may use options.InformationOnly and also adjust *options to be appropriate for editing the returned
+// value.
+// This does not change the state of the original manifestOCI1 object.
+func (m *manifestOCI1) convertToManifestSchema1(ctx context.Context, options *types.ManifestUpdateOptions) (genericManifest, error) {
+ if m.m.Config.MediaType != imgspecv1.MediaTypeImageConfig {
+ return nil, internalManifest.NewNonImageArtifactError(&m.m.Manifest)
+ }
+
+ // We can't directly convert images to V1, but we can transitively convert via a V2 image
+ m2, err := m.convertToManifestSchema2(ctx, options)
+ if err != nil {
+ return nil, err
+ }
+
+ return m2.convertToManifestSchema1(ctx, options)
+}
+
+// SupportsEncryption returns if encryption is supported for the manifest type
+func (m *manifestOCI1) SupportsEncryption(context.Context) bool {
+ return true
+}
+
+// CanChangeLayerCompression returns true if we can compress/decompress layers with mimeType in the current image
+// (and the code can handle that).
+// NOTE: Even if this returns true, the relevant format might not accept all compression algorithms; the set of accepted
+// algorithms depends not on the current format, but possibly on the target of a conversion (if UpdatedImage converts
+// to a different manifest format).
+func (m *manifestOCI1) CanChangeLayerCompression(mimeType string) bool {
+ return m.m.CanChangeLayerCompression(mimeType)
+}
diff --git a/internal/image/oci_index.go b/internal/image/oci_index.go
new file mode 100644
index 0000000..0e945c8
--- /dev/null
+++ b/internal/image/oci_index.go
@@ -0,0 +1,34 @@
+package image
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/containers/image/v5/internal/manifest"
+ "github.com/containers/image/v5/types"
+)
+
+func manifestOCI1FromImageIndex(ctx context.Context, sys *types.SystemContext, src types.ImageSource, manblob []byte) (genericManifest, error) {
+ index, err := manifest.OCI1IndexFromManifest(manblob)
+ if err != nil {
+ return nil, fmt.Errorf("parsing OCI1 index: %w", err)
+ }
+ targetManifestDigest, err := index.ChooseInstance(sys)
+ if err != nil {
+ return nil, fmt.Errorf("choosing image instance: %w", err)
+ }
+ manblob, mt, err := src.GetManifest(ctx, &targetManifestDigest)
+ if err != nil {
+ return nil, fmt.Errorf("fetching target platform image selected from image index: %w", err)
+ }
+
+ matches, err := manifest.MatchesDigest(manblob, targetManifestDigest)
+ if err != nil {
+ return nil, fmt.Errorf("computing manifest digest: %w", err)
+ }
+ if !matches {
+ return nil, fmt.Errorf("Image manifest does not match selected manifest digest %s", targetManifestDigest)
+ }
+
+ return manifestInstanceFromBlob(ctx, sys, src, manblob, mt)
+}
diff --git a/internal/image/oci_test.go b/internal/image/oci_test.go
new file mode 100644
index 0000000..0ac22f3
--- /dev/null
+++ b/internal/image/oci_test.go
@@ -0,0 +1,891 @@
+package image
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/testing/mocks"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/pkg/compression"
+ compressiontypes "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/slices"
+)
+
+func manifestOCI1FromFixture(t *testing.T, src types.ImageSource, fixture string) genericManifest {
+ manifest, err := os.ReadFile(filepath.Join("fixtures", fixture))
+ require.NoError(t, err)
+
+ m, err := manifestOCI1FromManifest(src, manifest)
+ require.NoError(t, err)
+ return m
+}
+
+var layerDescriptorsLikeFixture = []imgspecv1.Descriptor{
+ {
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ },
+ {
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ },
+ {
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ URLs: []string{
+ "https://layer.url",
+ },
+ },
+ {
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ Annotations: map[string]string{
+ "test-annotation-2": "two",
+ },
+ },
+ {
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ },
+}
+
+func manifestOCI1FromComponentsLikeFixture(configBlob []byte) genericManifest {
+ return manifestOCI1FromComponents(imgspecv1.Descriptor{
+ MediaType: imgspecv1.MediaTypeImageConfig,
+ Size: 5940,
+ Digest: commonFixtureConfigDigest,
+ Annotations: map[string]string{
+ "test-annotation-1": "one",
+ },
+ }, nil, configBlob, layerDescriptorsLikeFixture)
+}
+
+func manifestOCI1FromComponentsWithExtraConfigFields(t *testing.T, src types.ImageSource) genericManifest {
+ configJSON, err := os.ReadFile("fixtures/oci1-config-extra-fields.json")
+ require.NoError(t, err)
+ return manifestOCI1FromComponents(imgspecv1.Descriptor{
+ MediaType: imgspecv1.MediaTypeImageConfig,
+ Size: 7693,
+ Digest: "sha256:7f2a783ee2f07826b1856e68a40c930cd0430d6e7d4a88c29c2c8b7718706e74",
+ Annotations: map[string]string{
+ "test-annotation-1": "one",
+ },
+ }, src, configJSON, layerDescriptorsLikeFixture)
+}
+
+func TestManifestOCI1FromManifest(t *testing.T) {
+ // This just tests that the JSON can be loaded; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ _ = manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json")
+
+ _, err := manifestOCI1FromManifest(nil, []byte{})
+ assert.Error(t, err)
+}
+
+func TestManifestOCI1FromComponents(t *testing.T) {
+ // This just smoke-tests that the manifest can be created; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ _ = manifestOCI1FromComponentsLikeFixture(nil)
+}
+
+func TestManifestOCI1Serialize(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ serialized, err := m.serialize()
+ require.NoError(t, err)
+ // We would ideally like to compare “serialized” with some transformation of
+ // the original fixture, but the ordering of fields in JSON maps is undefined, so this is
+ // easier.
+ assertJSONEqualsFixture(t, serialized, "oci1.json")
+ }
+}
+
+func TestManifestOCI1ManifestMIMEType(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ assert.Equal(t, imgspecv1.MediaTypeImageManifest, m.manifestMIMEType())
+ }
+}
+
+func TestManifestOCI1ConfigInfo(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ assert.Equal(t, types.BlobInfo{
+ Size: 5940,
+ Digest: commonFixtureConfigDigest,
+ Annotations: map[string]string{
+ "test-annotation-1": "one",
+ },
+ MediaType: "application/vnd.oci.image.config.v1+json",
+ }, m.ConfigInfo())
+ }
+}
+
+func TestManifestOCI1ConfigBlob(t *testing.T) {
+ realConfigJSON, err := os.ReadFile("fixtures/oci1-config.json")
+ require.NoError(t, err)
+
+ for _, c := range []struct {
+ cbISfn func() (io.ReadCloser, int64, error)
+ blob []byte
+ }{
+ // Success
+ {func() (io.ReadCloser, int64, error) {
+ return io.NopCloser(bytes.NewReader(realConfigJSON)), int64(len(realConfigJSON)), nil
+ }, realConfigJSON},
+ // Various kinds of failures
+ {nil, nil},
+ {func() (io.ReadCloser, int64, error) {
+ return nil, -1, errors.New("Error returned from GetBlob")
+ }, nil},
+ {func() (io.ReadCloser, int64, error) {
+ reader, writer := io.Pipe()
+ err = writer.CloseWithError(errors.New("Expected error reading input in ConfigBlob"))
+ require.NoError(t, err)
+ return reader, 1, nil
+ }, nil},
+ {func() (io.ReadCloser, int64, error) {
+ nonmatchingJSON := []byte("This does not match ConfigDescriptor.Digest")
+ return io.NopCloser(bytes.NewReader(nonmatchingJSON)), int64(len(nonmatchingJSON)), nil
+ }, nil},
+ } {
+ var src types.ImageSource
+ if c.cbISfn != nil {
+ src = configBlobImageSource{
+ expectedDigest: commonFixtureConfigDigest,
+ f: c.cbISfn,
+ }
+ } else {
+ src = nil
+ }
+ m := manifestOCI1FromFixture(t, src, "oci1.json")
+ blob, err := m.ConfigBlob(context.Background())
+ if c.blob != nil {
+ assert.NoError(t, err)
+ assert.Equal(t, c.blob, blob)
+ } else {
+ assert.Error(t, err)
+ }
+ }
+
+ // Generally configBlob should match ConfigInfo; we don’t quite need it to, and this will
+ // guarantee that the returned object is returning the original contents instead
+ // of reading an object from elsewhere.
+ configBlob := []byte("config blob which does not match ConfigInfo")
+ // This just tests that the manifest can be created; we test that the parsed
+ // values are correctly returned in tests for the individual getter methods.
+ m := manifestOCI1FromComponentsLikeFixture(configBlob)
+ cb, err := m.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, configBlob, cb)
+}
+
+func TestManifestOCI1OCIConfig(t *testing.T) {
+ // Just a smoke-test that the code can read the data…
+ configJSON, err := os.ReadFile("fixtures/oci1-config.json")
+ require.NoError(t, err)
+ expectedConfig := imgspecv1.Image{}
+ err = json.Unmarshal(configJSON, &expectedConfig)
+ require.NoError(t, err)
+
+ originalSrc := newOCI1ImageSource(t, "oci1-config.json", "httpd:latest")
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, originalSrc, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(configJSON),
+ } {
+ config, err := m.OCIConfig(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, &expectedConfig, config)
+ }
+
+ // “Any extra fields in the Image JSON struct are considered implementation specific
+ // and MUST NOT generate an error by any implementations which are unable to interpret them.”
+ // oci1-config-extra-fields.json is the same as oci1-config.json, apart from a few added fields.
+ srcWithExtraFields := newOCI1ImageSource(t, "oci1-config-extra-fields.json", "httpd:latest")
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, srcWithExtraFields, "oci1-extra-config-fields.json"),
+ manifestOCI1FromComponentsWithExtraConfigFields(t, srcWithExtraFields),
+ } {
+ config, err := m.OCIConfig(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, &expectedConfig, config)
+ }
+
+ // This can share originalSrc because the config digest is the same between oci1-artifact.json and oci1.json
+ artifact := manifestOCI1FromFixture(t, originalSrc, "oci1-artifact.json")
+ _, err = artifact.OCIConfig(context.Background())
+ var expected manifest.NonImageArtifactError
+ assert.ErrorAs(t, err, &expected)
+}
+
+func TestManifestOCI1LayerInfo(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ URLs: []string{
+ "https://layer.url",
+ },
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ Annotations: map[string]string{
+ "test-annotation-2": "two",
+ },
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: imgspecv1.MediaTypeImageLayerGzip,
+ },
+ }, m.LayerInfos())
+ }
+}
+
+func TestManifestOCI1EmbeddedDockerReferenceConflicts(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ for _, name := range []string{"busybox", "example.com:5555/ns/repo:tag"} {
+ ref, err := reference.ParseNormalizedNamed(name)
+ require.NoError(t, err)
+ conflicts := m.EmbeddedDockerReferenceConflicts(ref)
+ assert.False(t, conflicts)
+ }
+ }
+}
+
+func TestManifestOCI1Inspect(t *testing.T) {
+ var emptyAnnotations map[string]string
+ created := time.Date(2016, 9, 23, 23, 20, 45, 789764590, time.UTC)
+
+ configJSON, err := os.ReadFile("fixtures/oci1-config.json")
+ require.NoError(t, err)
+ for _, m := range []genericManifest{
+ manifestOCI1FromComponentsLikeFixture(configJSON),
+ // “Any extra fields in the Image JSON struct are considered implementation specific
+ // and MUST NOT generate an error by any implementations which are unable to interpret them.”
+ // oci1-config-extra-fields.json is the same as oci1-config.json, apart from a few added fields.
+ manifestOCI1FromComponentsWithExtraConfigFields(t, nil),
+ } {
+ ii, err := m.Inspect(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, types.ImageInspectInfo{
+ Tag: "",
+ Created: &created,
+ DockerVersion: "1.12.1",
+ Labels: map[string]string{},
+ Architecture: "amd64",
+ Os: "linux",
+ Layers: []string{
+ "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ },
+ LayersData: []types.ImageInspectLayer{{
+ MIMEType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ Annotations: emptyAnnotations,
+ }, {
+ MIMEType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ Annotations: map[string]string{"test-annotation-2": "two"},
+ }, {
+ MIMEType: "application/vnd.oci.image.layer.v1.tar+gzip",
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ Annotations: emptyAnnotations,
+ },
+ },
+ Author: "",
+ Env: []string{
+ "PATH=/usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "HTTPD_PREFIX=/usr/local/apache2",
+ "HTTPD_VERSION=2.4.23",
+ "HTTPD_SHA1=5101be34ac4a509b245adb70a56690a84fcc4e7f",
+ "HTTPD_BZ2_URL=https://www.apache.org/dyn/closer.cgi?action=download&filename=httpd/httpd-2.4.23.tar.bz2",
+ "HTTPD_ASC_URL=https://www.apache.org/dist/httpd/httpd-2.4.23.tar.bz2.asc",
+ },
+ }, *ii)
+ }
+
+ // nil configBlob will trigger an error in m.ConfigBlob()
+ m := manifestOCI1FromComponentsLikeFixture(nil)
+ _, err = m.Inspect(context.Background())
+ assert.Error(t, err)
+
+ m = manifestOCI1FromComponentsLikeFixture([]byte("invalid JSON"))
+ _, err = m.Inspect(context.Background())
+ assert.Error(t, err)
+}
+
+func TestManifestOCI1UpdatedImageNeedsLayerDiffIDs(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ assert.False(t, m.UpdatedImageNeedsLayerDiffIDs(types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ }))
+ }
+}
+
+// oci1ImageSource is plausible enough for schema conversions in manifestOCI1.UpdatedImage() to work.
+type oci1ImageSource struct {
+ configBlobImageSource
+ ref reference.Named
+}
+
+func (OCIis *oci1ImageSource) Reference() types.ImageReference {
+ return refImageReferenceMock{ref: OCIis.ref}
+}
+
+func newOCI1ImageSource(t *testing.T, configFixture string, dockerRef string) *oci1ImageSource {
+ realConfigJSON, err := os.ReadFile(filepath.Join("fixtures", configFixture))
+ require.NoError(t, err)
+
+ ref, err := reference.ParseNormalizedNamed(dockerRef)
+ require.NoError(t, err)
+
+ return &oci1ImageSource{
+ configBlobImageSource: configBlobImageSource{
+ expectedDigest: digest.FromBytes(realConfigJSON),
+ f: func() (io.ReadCloser, int64, error) {
+ return io.NopCloser(bytes.NewReader(realConfigJSON)), int64(len(realConfigJSON)), nil
+ },
+ },
+ ref: ref,
+ }
+}
+
+func TestManifestOCI1UpdatedImage(t *testing.T) {
+ originalSrc := newOCI1ImageSource(t, "oci1-config.json", "httpd:latest")
+ original := manifestOCI1FromFixture(t, originalSrc, "oci1.json")
+
+ // LayerInfos:
+ layerInfos := append(slices.Clone(original.LayerInfos()[1:]), original.LayerInfos()[0])
+ res, err := original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfos,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, layerInfos, res.LayerInfos())
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: append(layerInfos, layerInfos[0]),
+ })
+ assert.Error(t, err)
+
+ // EmbeddedDockerReference:
+ // … is ignored
+ embeddedRef, err := reference.ParseNormalizedNamed("busybox")
+ require.NoError(t, err)
+ res, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ EmbeddedDockerReference: embeddedRef,
+ })
+ require.NoError(t, err)
+ nonEmbeddedRef, err := reference.ParseNormalizedNamed("notbusybox:notlatest")
+ require.NoError(t, err)
+ conflicts := res.EmbeddedDockerReferenceConflicts(nonEmbeddedRef)
+ assert.False(t, conflicts)
+
+ // ManifestMIMEType:
+ // Only smoke-test the valid conversions, detailed tests are below. (This also verifies that “original” is not affected.)
+ for _, mime := range []string{
+ manifest.DockerV2Schema2MediaType,
+ } {
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: mime,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: &memoryImageDest{ref: originalSrc.ref},
+ },
+ })
+ assert.NoError(t, err, mime)
+ }
+ for _, mime := range []string{
+ imgspecv1.MediaTypeImageManifest, // This indicates a confused caller, not a no-op.
+ "this is invalid",
+ } {
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: mime,
+ })
+ assert.Error(t, err, mime)
+ }
+
+ // original hasn’t been changed:
+ m2 := manifestOCI1FromFixture(t, originalSrc, "oci1.json")
+ typedOriginal, ok := original.(*manifestOCI1)
+ require.True(t, ok)
+ typedM2, ok := m2.(*manifestOCI1)
+ require.True(t, ok)
+ assert.Equal(t, *typedM2, *typedOriginal)
+}
+
+// successfulOCI1Conversion verifies that an edit of original with edits suceeeds, and and original continues to match originalClone.
+// It returns the resulting image, for more checks
+func successfulOCI1Conversion(t *testing.T, original genericManifest, originalClone genericManifest,
+ edits types.ManifestUpdateOptions) types.Image {
+ res, err := original.UpdatedImage(context.Background(), edits)
+ require.NoError(t, err)
+
+ // original = the source Image implementation hasn’t been changed by the edits
+ typedOriginal, ok := original.(*manifestOCI1)
+ require.True(t, ok)
+ typedOriginalClone, ok := originalClone.(*manifestOCI1)
+ require.True(t, ok)
+ assert.Equal(t, *typedOriginalClone, *typedOriginal)
+
+ return res
+}
+
+func TestManifestOCI1ConvertToManifestSchema1(t *testing.T) {
+ originalSrc := newOCI1ImageSource(t, "oci1-config.json", "httpd-copy:latest")
+ original := manifestOCI1FromFixture(t, originalSrc, "oci1.json")
+ original2 := manifestOCI1FromFixture(t, originalSrc, "oci1.json")
+ memoryDest := &memoryImageDest{ref: originalSrc.ref}
+ res := successfulOCI1Conversion(t, original, original2, types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, mt)
+ assertJSONEqualsFixture(t, convertedJSON, "oci1-to-schema1.json", "signatures")
+
+ assert.Equal(t, GzippedEmptyLayer, memoryDest.storedBlobs[GzippedEmptyLayerDigest])
+
+ // Conversion to schema1 together with changing LayerInfos works as expected (which requires
+ // handling schema1 empty layers):
+ updatedLayers, updatedLayersCopy := modifiedLayerInfos(t, original.LayerInfos())
+ res = successfulOCI1Conversion(t, original, original2, types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, mt)
+ // Layers have been updated as expected
+ s1Manifest, err := manifestSchema1FromManifest(convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5ba", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680d", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a8", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25908", Size: -1},
+ {Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fb", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ }, s1Manifest.LayerInfos())
+
+ // This can share originalSrc because the config digest is the same between oci1-artifact.json and oci1.json
+ artifact := manifestOCI1FromFixture(t, originalSrc, "oci1-artifact.json")
+ _, err = artifact.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ var expected manifest.NonImageArtifactError
+ assert.ErrorAs(t, err, &expected)
+
+ // Conversion of an encrypted image fails
+ encrypted := manifestOCI1FromFixture(t, originalSrc, "oci1.encrypted.json")
+ encrypted2 := manifestOCI1FromFixture(t, originalSrc, "oci1.encrypted.json")
+ _, err = encrypted.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Error(t, err)
+
+ // Conversion to schema1 with encryption fails
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfosWithCryptoOperation(original.LayerInfos(), types.Encrypt),
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Error(t, err)
+
+ // Conversion to schema1 with simultaneous decryption is possible
+ updatedLayers = layerInfosWithCryptoOperation(encrypted.LayerInfos(), types.Decrypt)
+ updatedLayersCopy = slices.Clone(updatedLayers)
+ res = successfulOCI1Conversion(t, encrypted, encrypted2, types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, mt)
+ // Layers have been updated as expected
+ s1Manifest, err = manifestSchema1FromManifest(convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Size: -1},
+ {Digest: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ }, s1Manifest.LayerInfos())
+
+ // Conversion to schema1 of an image with Zstd layers fails
+ mixedSrc := newOCI1ImageSource(t, "oci1-all-media-types-config.json", "httpd-copy:latest")
+ mixedImage := manifestOCI1FromFixture(t, mixedSrc, "oci1-all-media-types.json")
+ mixedImage2 := manifestOCI1FromFixture(t, mixedSrc, "oci1-all-media-types.json")
+ _, err = mixedImage.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Error(t, err) // zstd compression is not supported for docker images
+
+ // Conversion to schema1 of an image with Zstd layers, while editing layers to be uncompressed, or gzip-compressed, is possible.
+ for _, c := range []struct {
+ op types.LayerCompression
+ algo *compressiontypes.Algorithm
+ }{
+ {types.Decompress, nil},
+ {types.PreserveOriginal, &compression.Gzip},
+ } {
+ updatedLayers = layerInfosWithCompressionEdits(mixedImage.LayerInfos(), c.op, c.algo)
+ updatedLayersCopy = slices.Clone(updatedLayers)
+ res = successfulOCI1Conversion(t, mixedImage, mixedImage2, types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema1SignedMediaType,
+ InformationOnly: types.ManifestUpdateInformation{
+ Destination: memoryDest,
+ },
+ })
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema1SignedMediaType, mt)
+ s1Manifest, err = manifestSchema1FromManifest(convertedJSON)
+ require.NoError(t, err)
+ // The schema1 data does not contain a MIME type (and we don’t update the digests), so both loop iterations look the same here
+ assert.Equal(t, []types.BlobInfo{
+ {Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9", Size: -1},
+ {Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909", Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: GzippedEmptyLayerDigest, Size: -1},
+ {Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa", Size: -1},
+ }, s1Manifest.LayerInfos())
+ }
+
+ // FIXME? Test also the other failure cases, if only to see that we don't crash?
+}
+
+func TestConvertToManifestSchema2(t *testing.T) {
+ originalSrc := newOCI1ImageSource(t, "oci1-config.json", "httpd-copy:latest")
+ original := manifestOCI1FromFixture(t, originalSrc, "oci1.json")
+ original2 := manifestOCI1FromFixture(t, originalSrc, "oci1.json")
+ res := successfulOCI1Conversion(t, original, original2, types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+
+ convertedJSON, mt, err := res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, mt)
+ assertJSONEqualsFixture(t, convertedJSON, "oci1-to-schema2.json")
+
+ convertedConfig, err := res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "oci1-to-schema2-config.json")
+
+ // This can share originalSrc because the config digest is the same between oci1-artifact.json and oci1.json
+ artifact := manifestOCI1FromFixture(t, originalSrc, "oci1-artifact.json")
+ _, err = artifact.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ var expected manifest.NonImageArtifactError
+ assert.ErrorAs(t, err, &expected)
+
+ // Conversion of an encrypted image fails
+ encrypted := manifestOCI1FromFixture(t, originalSrc, "oci1.encrypted.json")
+ encrypted2 := manifestOCI1FromFixture(t, originalSrc, "oci1.encrypted.json")
+ _, err = encrypted.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ assert.Error(t, err)
+
+ // Conversion to schema2 with encryption fails
+ _, err = original.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ LayerInfos: layerInfosWithCryptoOperation(original.LayerInfos(), types.Encrypt),
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ assert.Error(t, err)
+
+ // Conversion to schema2 with simultaneous decryption is possible
+ updatedLayers := layerInfosWithCryptoOperation(encrypted.LayerInfos(), types.Decrypt)
+ updatedLayersCopy := slices.Clone(updatedLayers)
+ res = successfulOCI1Conversion(t, encrypted, encrypted2, types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, mt)
+ s2Manifest, err := manifestSchema2FromManifest(originalSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ Size: 51354364,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ Size: 150,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ Size: 11739507,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ URLs: []string{"https://layer.url"},
+ },
+ {
+ Digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ Size: 8841833,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ Size: 291,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ }, s2Manifest.LayerInfos())
+ convertedConfig, err = res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "oci1-to-schema2-config.json")
+
+ // Conversion to schema2 of an image with Zstd layers fails
+ mixedSrc := newOCI1ImageSource(t, "oci1-all-media-types-config.json", "httpd-copy:latest")
+ mixedImage := manifestOCI1FromFixture(t, mixedSrc, "oci1-all-media-types.json")
+ mixedImage2 := manifestOCI1FromFixture(t, mixedSrc, "oci1-all-media-types.json")
+ _, err = mixedImage.UpdatedImage(context.Background(), types.ManifestUpdateOptions{
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ assert.Error(t, err) // zstd compression is not supported for docker images
+
+ // Conversion to schema2 of an image with Zstd layers, while editing layers to be uncompressed, is possible.
+ updatedLayers = layerInfosWithCompressionEdits(mixedImage.LayerInfos(), types.Decompress, nil)
+ updatedLayersCopy = slices.Clone(updatedLayers)
+ res = successfulOCI1Conversion(t, mixedImage, mixedImage2, types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, mt)
+ s2Manifest, err = manifestSchema2FromManifest(mixedSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar",
+ },
+ {
+ Digest: "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 152,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar",
+ },
+ }, s2Manifest.LayerInfos())
+ convertedConfig, err = res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "oci1-all-media-types-to-schema2-config.json")
+
+ // Conversion to schema2 of an image with Zstd layers, while editing layers to be gzip-compressed, is possible.
+ updatedLayers = layerInfosWithCompressionEdits(mixedImage.LayerInfos(), types.PreserveOriginal, &compression.Gzip)
+ updatedLayersCopy = slices.Clone(updatedLayers)
+ res = successfulOCI1Conversion(t, mixedImage, mixedImage2, types.ManifestUpdateOptions{
+ LayerInfos: updatedLayers,
+ ManifestMIMEType: manifest.DockerV2Schema2MediaType,
+ })
+ assert.Equal(t, updatedLayersCopy, updatedLayers) // updatedLayers have not been modified in place
+ convertedJSON, mt, err = res.Manifest(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, manifest.DockerV2Schema2MediaType, mt)
+ s2Manifest, err = manifestSchema2FromManifest(mixedSrc, convertedJSON)
+ require.NoError(t, err)
+ assert.Equal(t, []types.BlobInfo{
+ {
+ Digest: "sha256:6a5a5368e0c2d3e5909184fa28ddfd56072e7ff3ee9a945876f7eee5896ef5bb",
+ Size: 51354364,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:1bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 150,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:2bbf5d58d24c47512e234a5623474acf65ae00d4d1414272a893204f44cc680c",
+ Size: 152,
+ MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:8f5dc8a4b12c307ac84de90cdd9a7f3915d1be04c9388868ca118831099c67a9",
+ Size: 11739507,
+ MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:bbd6b22eb11afce63cc76f6bc41042d99f10d6024c96b655dafba930b8d25909",
+ Size: 8841833,
+ MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ },
+ {
+ Digest: "sha256:960e52ecf8200cbd84e70eb2ad8678f4367e50d14357021872c10fa3fc5935fa",
+ Size: 291,
+ MediaType: "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
+ },
+ }, s2Manifest.LayerInfos())
+ convertedConfig, err = res.ConfigBlob(context.Background())
+ require.NoError(t, err)
+ assertJSONEqualsFixture(t, convertedConfig, "oci1-all-media-types-to-schema2-config.json")
+
+ // FIXME? Test also the other failure cases, if only to see that we don't crash?
+}
+
+func TestConvertToV2S2WithInvalidMIMEType(t *testing.T) {
+ originalSrc := newOCI1ImageSource(t, "oci1-config.json", "httpd-copy:latest")
+ manifest, err := os.ReadFile(filepath.Join("fixtures", "oci1-invalid-media-type.json"))
+ require.NoError(t, err)
+
+ _, err = manifestOCI1FromManifest(originalSrc, manifest)
+ require.NoError(t, err)
+}
+
+func TestManifestOCI1CanChangeLayerCompression(t *testing.T) {
+ for _, m := range []genericManifest{
+ manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1.json"),
+ manifestOCI1FromComponentsLikeFixture(nil),
+ } {
+ assert.True(t, m.CanChangeLayerCompression(imgspecv1.MediaTypeImageLayerGzip))
+ // Some projects like to use squashfs and other unspecified formats for layers; don’t touch those.
+ assert.False(t, m.CanChangeLayerCompression("a completely unknown and quite possibly invalid MIME type"))
+ }
+
+ artifact := manifestOCI1FromFixture(t, mocks.ForbiddenImageSource{}, "oci1-artifact.json")
+ assert.False(t, artifact.CanChangeLayerCompression(imgspecv1.MediaTypeImageLayerGzip))
+}
diff --git a/internal/image/sourced.go b/internal/image/sourced.go
new file mode 100644
index 0000000..661891a
--- /dev/null
+++ b/internal/image/sourced.go
@@ -0,0 +1,134 @@
+// Package image consolidates knowledge about various container image formats
+// (as opposed to image storage mechanisms, which are handled by types.ImageSource)
+// and exposes all of them using an unified interface.
+package image
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/types"
+)
+
+// FromReference returns a types.ImageCloser implementation for the default instance reading from reference.
+// If reference points to a manifest list, .Manifest() still returns the manifest list,
+// but other methods transparently return data from an appropriate image instance.
+//
+// The caller must call .Close() on the returned ImageCloser.
+//
+// NOTE: If any kind of signature verification should happen, build an UnparsedImage from the value returned by NewImageSource,
+// verify that UnparsedImage, and convert it into a real Image via image.FromUnparsedImage instead of calling this function.
+func FromReference(ctx context.Context, sys *types.SystemContext, ref types.ImageReference) (types.ImageCloser, error) {
+ src, err := ref.NewImageSource(ctx, sys)
+ if err != nil {
+ return nil, err
+ }
+ img, err := FromSource(ctx, sys, src)
+ if err != nil {
+ src.Close()
+ return nil, err
+ }
+ return img, nil
+}
+
+// imageCloser implements types.ImageCloser, perhaps allowing simple users
+// to use a single object without having keep a reference to a types.ImageSource
+// only to call types.ImageSource.Close().
+type imageCloser struct {
+ types.Image
+ src types.ImageSource
+}
+
+// FromSource returns a types.ImageCloser implementation for the default instance of source.
+// If source is a manifest list, .Manifest() still returns the manifest list,
+// but other methods transparently return data from an appropriate image instance.
+//
+// The caller must call .Close() on the returned ImageCloser.
+//
+// FromSource “takes ownership” of the input ImageSource and will call src.Close()
+// when the image is closed. (This does not prevent callers from using both the
+// Image and ImageSource objects simultaneously, but it means that they only need to
+// the Image.)
+//
+// NOTE: If any kind of signature verification should happen, build an UnparsedImage from the value returned by NewImageSource,
+// verify that UnparsedImage, and convert it into a real Image via image.FromUnparsedImage instead of calling this function.
+//
+// Most callers can use either FromUnparsedImage or FromReference instead.
+//
+// This is publicly visible as c/image/image.FromSource.
+func FromSource(ctx context.Context, sys *types.SystemContext, src types.ImageSource) (types.ImageCloser, error) {
+ img, err := FromUnparsedImage(ctx, sys, UnparsedInstance(src, nil))
+ if err != nil {
+ return nil, err
+ }
+ return &imageCloser{
+ Image: img,
+ src: src,
+ }, nil
+}
+
+func (ic *imageCloser) Close() error {
+ return ic.src.Close()
+}
+
+// SourcedImage is a general set of utilities for working with container images,
+// whatever is their underlying transport (i.e. ImageSource-independent).
+// Note the existence of docker.Image and image.memoryImage: various instances
+// of a types.Image may not be a SourcedImage directly.
+//
+// Most external users of `types.Image` do not care, and those who care about `docker.Image` know they do.
+//
+// Internal users may depend on methods available in SourcedImage but not (yet?) in types.Image.
+type SourcedImage struct {
+ *UnparsedImage
+ ManifestBlob []byte // The manifest of the relevant instance
+ ManifestMIMEType string // MIME type of ManifestBlob
+ // genericManifest contains data corresponding to manifestBlob.
+ // NOTE: The manifest may have been modified in the process; DO NOT reserialize and store genericManifest
+ // if you want to preserve the original manifest; use manifestBlob directly.
+ genericManifest
+}
+
+// FromUnparsedImage returns a types.Image implementation for unparsed.
+// If unparsed represents a manifest list, .Manifest() still returns the manifest list,
+// but other methods transparently return data from an appropriate single image.
+//
+// The Image must not be used after the underlying ImageSource is Close()d.
+//
+// This is publicly visible as c/image/image.FromUnparsedImage.
+func FromUnparsedImage(ctx context.Context, sys *types.SystemContext, unparsed *UnparsedImage) (*SourcedImage, error) {
+ // Note that the input parameter above is specifically *image.UnparsedImage, not types.UnparsedImage:
+ // we want to be able to use unparsed.src. We could make that an explicit interface, but, well,
+ // this is the only UnparsedImage implementation around, anyway.
+
+ // NOTE: It is essential for signature verification that all parsing done in this object happens on the same manifest which is returned by unparsed.Manifest().
+ manifestBlob, manifestMIMEType, err := unparsed.Manifest(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ parsedManifest, err := manifestInstanceFromBlob(ctx, sys, unparsed.src, manifestBlob, manifestMIMEType)
+ if err != nil {
+ return nil, err
+ }
+
+ return &SourcedImage{
+ UnparsedImage: unparsed,
+ ManifestBlob: manifestBlob,
+ ManifestMIMEType: manifestMIMEType,
+ genericManifest: parsedManifest,
+ }, nil
+}
+
+// Size returns the size of the image as stored, if it's known, or -1 if it isn't.
+func (i *SourcedImage) Size() (int64, error) {
+ return -1, nil
+}
+
+// Manifest overrides the UnparsedImage.Manifest to always use the fields which we have already fetched.
+func (i *SourcedImage) Manifest(ctx context.Context) ([]byte, string, error) {
+ return i.ManifestBlob, i.ManifestMIMEType, nil
+}
+
+func (i *SourcedImage) LayerInfosForCopy(ctx context.Context) ([]types.BlobInfo, error) {
+ return i.UnparsedImage.src.LayerInfosForCopy(ctx, i.UnparsedImage.instanceDigest)
+}
diff --git a/internal/image/unparsed.go b/internal/image/unparsed.go
new file mode 100644
index 0000000..0f02650
--- /dev/null
+++ b/internal/image/unparsed.go
@@ -0,0 +1,119 @@
+package image
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/imagesource"
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/containers/image/v5/manifest"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// UnparsedImage implements types.UnparsedImage .
+// An UnparsedImage is a pair of (ImageSource, instance digest); it can represent either a manifest list or a single image instance.
+//
+// This is publicly visible as c/image/image.UnparsedImage.
+type UnparsedImage struct {
+ src private.ImageSource
+ instanceDigest *digest.Digest
+ cachedManifest []byte // A private cache for Manifest(); nil if not yet known.
+ // A private cache for Manifest(), may be the empty string if guessing failed.
+ // Valid iff cachedManifest is not nil.
+ cachedManifestMIMEType string
+ cachedSignatures []signature.Signature // A private cache for Signatures(); nil if not yet known.
+}
+
+// UnparsedInstance returns a types.UnparsedImage implementation for (source, instanceDigest).
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to retrieve (when the primary manifest is a manifest list).
+//
+// The UnparsedImage must not be used after the underlying ImageSource is Close()d.
+//
+// This is publicly visible as c/image/image.UnparsedInstance.
+func UnparsedInstance(src types.ImageSource, instanceDigest *digest.Digest) *UnparsedImage {
+ return &UnparsedImage{
+ src: imagesource.FromPublic(src),
+ instanceDigest: instanceDigest,
+ }
+}
+
+// Reference returns the reference used to set up this source, _as specified by the user_
+// (not as the image itself, or its underlying storage, claims). This can be used e.g. to determine which public keys are trusted for this image.
+func (i *UnparsedImage) Reference() types.ImageReference {
+ // Note that this does not depend on instanceDigest; e.g. all instances within a manifest list need to be signed with the manifest list identity.
+ return i.src.Reference()
+}
+
+// Manifest is like ImageSource.GetManifest, but the result is cached; it is OK to call this however often you need.
+func (i *UnparsedImage) Manifest(ctx context.Context) ([]byte, string, error) {
+ if i.cachedManifest == nil {
+ m, mt, err := i.src.GetManifest(ctx, i.instanceDigest)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // ImageSource.GetManifest does not do digest verification, but we do;
+ // this immediately protects also any user of types.Image.
+ if digest, haveDigest := i.expectedManifestDigest(); haveDigest {
+ matches, err := manifest.MatchesDigest(m, digest)
+ if err != nil {
+ return nil, "", fmt.Errorf("computing manifest digest: %w", err)
+ }
+ if !matches {
+ return nil, "", fmt.Errorf("Manifest does not match provided manifest digest %s", digest)
+ }
+ }
+
+ i.cachedManifest = m
+ i.cachedManifestMIMEType = mt
+ }
+ return i.cachedManifest, i.cachedManifestMIMEType, nil
+}
+
+// expectedManifestDigest returns a the expected value of the manifest digest, and an indicator whether it is known.
+// The bool return value seems redundant with digest != ""; it is used explicitly
+// to refuse (unexpected) situations when the digest exists but is "".
+func (i *UnparsedImage) expectedManifestDigest() (digest.Digest, bool) {
+ if i.instanceDigest != nil {
+ return *i.instanceDigest, true
+ }
+ ref := i.Reference().DockerReference()
+ if ref != nil {
+ if canonical, ok := ref.(reference.Canonical); ok {
+ return canonical.Digest(), true
+ }
+ }
+ return "", false
+}
+
+// Signatures is like ImageSource.GetSignatures, but the result is cached; it is OK to call this however often you need.
+func (i *UnparsedImage) Signatures(ctx context.Context) ([][]byte, error) {
+ // It would be consistent to make this an internal/unparsedimage/impl.Compat wrapper,
+ // but this is very likely to be the only implementation ever.
+ sigs, err := i.UntrustedSignatures(ctx)
+ if err != nil {
+ return nil, err
+ }
+ simpleSigs := [][]byte{}
+ for _, sig := range sigs {
+ if sig, ok := sig.(signature.SimpleSigning); ok {
+ simpleSigs = append(simpleSigs, sig.UntrustedSignature())
+ }
+ }
+ return simpleSigs, nil
+}
+
+// UntrustedSignatures is like ImageSource.GetSignaturesWithFormat, but the result is cached; it is OK to call this however often you need.
+func (i *UnparsedImage) UntrustedSignatures(ctx context.Context) ([]signature.Signature, error) {
+ if i.cachedSignatures == nil {
+ sigs, err := i.src.GetSignaturesWithFormat(ctx, i.instanceDigest)
+ if err != nil {
+ return nil, err
+ }
+ i.cachedSignatures = sigs
+ }
+ return i.cachedSignatures, nil
+}
diff --git a/internal/imagedestination/impl/compat.go b/internal/imagedestination/impl/compat.go
new file mode 100644
index 0000000..47c169a
--- /dev/null
+++ b/internal/imagedestination/impl/compat.go
@@ -0,0 +1,101 @@
+package impl
+
+import (
+ "context"
+ "io"
+
+ "github.com/containers/image/v5/internal/blobinfocache"
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// Compat implements the obsolete parts of types.ImageDestination
+// for implementations of private.ImageDestination.
+// See AddCompat below.
+type Compat struct {
+ dest private.ImageDestinationInternalOnly
+}
+
+// AddCompat initializes Compat to implement the obsolete parts of types.ImageDestination
+// for implementations of private.ImageDestination.
+//
+// Use it like this:
+//
+// type yourDestination struct {
+// impl.Compat
+// …
+// }
+//
+// dest := &yourDestination{…}
+// dest.Compat = impl.AddCompat(dest)
+func AddCompat(dest private.ImageDestinationInternalOnly) Compat {
+ return Compat{dest}
+}
+
+// PutBlob writes contents of stream and returns data representing the result.
+// inputInfo.Digest can be optionally provided if known; if provided, and stream is read to the end without error, the digest MUST match the stream contents.
+// inputInfo.Size is the expected length of stream, if known.
+// inputInfo.MediaType describes the blob format, if known.
+// May update cache.
+// WARNING: The contents of stream are being verified on the fly. Until stream.Read() returns io.EOF, the contents of the data SHOULD NOT be available
+// to any other readers for download using the supplied digest.
+// If stream.Read() at any time, ESPECIALLY at end of input, returns an error, PutBlob MUST 1) fail, and 2) delete any data stored so far.
+func (c *Compat) PutBlob(ctx context.Context, stream io.Reader, inputInfo types.BlobInfo, cache types.BlobInfoCache, isConfig bool) (types.BlobInfo, error) {
+ res, err := c.dest.PutBlobWithOptions(ctx, stream, inputInfo, private.PutBlobOptions{
+ Cache: blobinfocache.FromBlobInfoCache(cache),
+ IsConfig: isConfig,
+ })
+ if err != nil {
+ return types.BlobInfo{}, err
+ }
+ return types.BlobInfo{
+ Digest: res.Digest,
+ Size: res.Size,
+ }, nil
+}
+
+// TryReusingBlob checks whether the transport already contains, or can efficiently reuse, a blob, and if so, applies it to the current destination
+// (e.g. if the blob is a filesystem layer, this signifies that the changes it describes need to be applied again when composing a filesystem tree).
+// info.Digest must not be empty.
+// If canSubstitute, TryReusingBlob can use an equivalent equivalent of the desired blob; in that case the returned info may not match the input.
+// If the blob has been successfully reused, returns (true, info, nil); info must contain at least a digest and size, and may
+// include CompressionOperation and CompressionAlgorithm fields to indicate that a change to the compression type should be
+// reflected in the manifest that will be written.
+// If the transport can not reuse the requested blob, TryReusingBlob returns (false, {}, nil); it returns a non-nil error only on an unexpected failure.
+// May use and/or update cache.
+func (c *Compat) TryReusingBlob(ctx context.Context, info types.BlobInfo, cache types.BlobInfoCache, canSubstitute bool) (bool, types.BlobInfo, error) {
+ reused, blob, err := c.dest.TryReusingBlobWithOptions(ctx, info, private.TryReusingBlobOptions{
+ Cache: blobinfocache.FromBlobInfoCache(cache),
+ CanSubstitute: canSubstitute,
+ })
+ if !reused || err != nil {
+ return reused, types.BlobInfo{}, err
+ }
+ res := types.BlobInfo{
+ Digest: blob.Digest,
+ Size: blob.Size,
+ CompressionOperation: blob.CompressionOperation,
+ CompressionAlgorithm: blob.CompressionAlgorithm,
+ }
+ // This is probably not necessary; we preserve MediaType to decrease risks of breaking for external callers.
+ // Some transports were not setting the MediaType field anyway, and others were setting the old value on substitution;
+ // provide the value in cases where it is likely to be correct.
+ if blob.Digest == info.Digest {
+ res.MediaType = info.MediaType
+ }
+ return true, res, nil
+}
+
+// PutSignatures writes a set of signatures to the destination.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to write or overwrite the signatures for
+// (when the primary manifest is a manifest list); this should always be nil if the primary manifest is not a manifest list.
+// MUST be called after PutManifest (signatures may reference manifest contents).
+func (c *Compat) PutSignatures(ctx context.Context, signatures [][]byte, instanceDigest *digest.Digest) error {
+ withFormat := []signature.Signature{}
+ for _, sig := range signatures {
+ withFormat = append(withFormat, signature.SimpleSigningFromBlob(sig))
+ }
+ return c.dest.PutSignaturesWithFormat(ctx, withFormat, instanceDigest)
+}
diff --git a/internal/imagedestination/impl/helpers.go b/internal/imagedestination/impl/helpers.go
new file mode 100644
index 0000000..5d28b3e
--- /dev/null
+++ b/internal/imagedestination/impl/helpers.go
@@ -0,0 +1,25 @@
+package impl
+
+import (
+ "github.com/containers/image/v5/internal/private"
+ compression "github.com/containers/image/v5/pkg/compression/types"
+)
+
+// BlobMatchesRequiredCompression validates if compression is required by the caller while selecting a blob, if it is required
+// then function performs a match against the compression requested by the caller and compression of existing blob
+// (which can be nil to represent uncompressed or unknown)
+func BlobMatchesRequiredCompression(options private.TryReusingBlobOptions, candidateCompression *compression.Algorithm) bool {
+ if options.RequiredCompression == nil {
+ return true // no requirement imposed
+ }
+ if options.RequiredCompression.Name() == compression.ZstdChunkedAlgorithmName {
+ // HACK: Never match when the caller asks for zstd:chunked, because we don’t record the annotations required to use the chunked blobs.
+ // The caller must re-compress to build those annotations.
+ return false
+ }
+ return candidateCompression != nil && (options.RequiredCompression.Name() == candidateCompression.Name())
+}
+
+func OriginalBlobMatchesRequiredCompression(opts private.TryReusingBlobOptions) bool {
+ return BlobMatchesRequiredCompression(opts, opts.OriginalCompression)
+}
diff --git a/internal/imagedestination/impl/helpers_test.go b/internal/imagedestination/impl/helpers_test.go
new file mode 100644
index 0000000..8a80d1d
--- /dev/null
+++ b/internal/imagedestination/impl/helpers_test.go
@@ -0,0 +1,29 @@
+package impl
+
+import (
+ "testing"
+
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/pkg/compression"
+ compressionTypes "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBlobMatchesRequiredCompression(t *testing.T) {
+ var opts private.TryReusingBlobOptions
+ cases := []struct {
+ requiredCompression *compressionTypes.Algorithm
+ candidateCompression *compressionTypes.Algorithm
+ result bool
+ }{
+ {&compression.Zstd, &compression.Zstd, true},
+ {&compression.Gzip, &compression.Zstd, false},
+ {&compression.Zstd, nil, false},
+ {nil, &compression.Zstd, true},
+ }
+
+ for _, c := range cases {
+ opts = private.TryReusingBlobOptions{RequiredCompression: c.requiredCompression}
+ assert.Equal(t, c.result, BlobMatchesRequiredCompression(opts, c.candidateCompression))
+ }
+}
diff --git a/internal/imagedestination/impl/properties.go b/internal/imagedestination/impl/properties.go
new file mode 100644
index 0000000..704812e
--- /dev/null
+++ b/internal/imagedestination/impl/properties.go
@@ -0,0 +1,72 @@
+package impl
+
+import "github.com/containers/image/v5/types"
+
+// Properties collects properties of an ImageDestination that are constant throughout its lifetime
+// (but might differ across instances).
+type Properties struct {
+ // SupportedManifestMIMETypes tells which manifest MIME types the destination supports.
+ // A empty slice or nil means any MIME type can be tried to upload.
+ SupportedManifestMIMETypes []string
+ // DesiredLayerCompression indicates the kind of compression to apply on layers
+ DesiredLayerCompression types.LayerCompression
+ // AcceptsForeignLayerURLs is false if foreign layers in manifest should be actually
+ // uploaded to the image destination, true otherwise.
+ AcceptsForeignLayerURLs bool
+ // MustMatchRuntimeOS is set to true if the destination can store only images targeted for the current runtime architecture and OS.
+ MustMatchRuntimeOS bool
+ // IgnoresEmbeddedDockerReference is set to true if the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
+ // and would prefer to receive an unmodified manifest instead of one modified for the destination.
+ // Does not make a difference if Reference().DockerReference() is nil.
+ IgnoresEmbeddedDockerReference bool
+ // HasThreadSafePutBlob indicates that PutBlob can be executed concurrently.
+ HasThreadSafePutBlob bool
+}
+
+// PropertyMethodsInitialize implements parts of private.ImageDestination corresponding to Properties.
+type PropertyMethodsInitialize struct {
+ // We need two separate structs, PropertyMethodsInitialize and Properties, because Go prohibits fields and methods with the same name.
+
+ vals Properties
+}
+
+// PropertyMethods creates an PropertyMethodsInitialize for vals.
+func PropertyMethods(vals Properties) PropertyMethodsInitialize {
+ return PropertyMethodsInitialize{
+ vals: vals,
+ }
+}
+
+// SupportedManifestMIMETypes tells which manifest mime types the destination supports
+// If an empty slice or nil it's returned, then any mime type can be tried to upload
+func (o PropertyMethodsInitialize) SupportedManifestMIMETypes() []string {
+ return o.vals.SupportedManifestMIMETypes
+}
+
+// DesiredLayerCompression indicates the kind of compression to apply on layers
+func (o PropertyMethodsInitialize) DesiredLayerCompression() types.LayerCompression {
+ return o.vals.DesiredLayerCompression
+}
+
+// AcceptsForeignLayerURLs returns false iff foreign layers in manifest should be actually
+// uploaded to the image destination, true otherwise.
+func (o PropertyMethodsInitialize) AcceptsForeignLayerURLs() bool {
+ return o.vals.AcceptsForeignLayerURLs
+}
+
+// MustMatchRuntimeOS returns true iff the destination can store only images targeted for the current runtime architecture and OS. False otherwise.
+func (o PropertyMethodsInitialize) MustMatchRuntimeOS() bool {
+ return o.vals.MustMatchRuntimeOS
+}
+
+// IgnoresEmbeddedDockerReference() returns true iff the destination does not care about Image.EmbeddedDockerReferenceConflicts(),
+// and would prefer to receive an unmodified manifest instead of one modified for the destination.
+// Does not make a difference if Reference().DockerReference() is nil.
+func (o PropertyMethodsInitialize) IgnoresEmbeddedDockerReference() bool {
+ return o.vals.IgnoresEmbeddedDockerReference
+}
+
+// HasThreadSafePutBlob indicates whether PutBlob can be executed concurrently.
+func (o PropertyMethodsInitialize) HasThreadSafePutBlob() bool {
+ return o.vals.HasThreadSafePutBlob
+}
diff --git a/internal/imagedestination/stubs/put_blob_partial.go b/internal/imagedestination/stubs/put_blob_partial.go
new file mode 100644
index 0000000..0dc6bd5
--- /dev/null
+++ b/internal/imagedestination/stubs/put_blob_partial.go
@@ -0,0 +1,52 @@
+package stubs
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/containers/image/v5/internal/blobinfocache"
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/types"
+)
+
+// NoPutBlobPartialInitialize implements parts of private.ImageDestination
+// for transports that don’t support PutBlobPartial().
+// See NoPutBlobPartial() below.
+type NoPutBlobPartialInitialize struct {
+ transportName string
+}
+
+// NoPutBlobPartial creates a NoPutBlobPartialInitialize for ref.
+func NoPutBlobPartial(ref types.ImageReference) NoPutBlobPartialInitialize {
+ return NoPutBlobPartialRaw(ref.Transport().Name())
+}
+
+// NoPutBlobPartialRaw is the same thing as NoPutBlobPartial, but it can be used
+// in situations where no ImageReference is available.
+func NoPutBlobPartialRaw(transportName string) NoPutBlobPartialInitialize {
+ return NoPutBlobPartialInitialize{
+ transportName: transportName,
+ }
+}
+
+// SupportsPutBlobPartial returns true if PutBlobPartial is supported.
+func (stub NoPutBlobPartialInitialize) SupportsPutBlobPartial() bool {
+ return false
+}
+
+// PutBlobPartial attempts to create a blob using the data that is already present
+// at the destination. chunkAccessor is accessed in a non-sequential way to retrieve the missing chunks.
+// It is available only if SupportsPutBlobPartial().
+// Even if SupportsPutBlobPartial() returns true, the call can fail, in which case the caller
+// should fall back to PutBlobWithOptions.
+func (stub NoPutBlobPartialInitialize) PutBlobPartial(ctx context.Context, chunkAccessor private.BlobChunkAccessor, srcInfo types.BlobInfo, cache blobinfocache.BlobInfoCache2) (private.UploadedBlob, error) {
+ return private.UploadedBlob{}, fmt.Errorf("internal error: PutBlobPartial is not supported by the %q transport", stub.transportName)
+}
+
+// ImplementsPutBlobPartial implements SupportsPutBlobPartial() that returns true.
+type ImplementsPutBlobPartial struct{}
+
+// SupportsPutBlobPartial returns true if PutBlobPartial is supported.
+func (stub ImplementsPutBlobPartial) SupportsPutBlobPartial() bool {
+ return true
+}
diff --git a/internal/imagedestination/stubs/signatures.go b/internal/imagedestination/stubs/signatures.go
new file mode 100644
index 0000000..7015fd0
--- /dev/null
+++ b/internal/imagedestination/stubs/signatures.go
@@ -0,0 +1,50 @@
+package stubs
+
+import (
+ "context"
+ "errors"
+
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/opencontainers/go-digest"
+)
+
+// NoSignaturesInitialize implements parts of private.ImageDestination
+// for transports that don’t support storing signatures.
+// See NoSignatures() below.
+type NoSignaturesInitialize struct {
+ message string
+}
+
+// NoSignatures creates a NoSignaturesInitialize, failing with message.
+func NoSignatures(message string) NoSignaturesInitialize {
+ return NoSignaturesInitialize{
+ message: message,
+ }
+}
+
+// SupportsSignatures returns an error (to be displayed to the user) if the destination certainly can't store signatures.
+// Note: It is still possible for PutSignatures to fail if SupportsSignatures returns nil.
+func (stub NoSignaturesInitialize) SupportsSignatures(ctx context.Context) error {
+ return errors.New(stub.message)
+}
+
+// PutSignaturesWithFormat writes a set of signatures to the destination.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to write or overwrite the signatures for
+// (when the primary manifest is a manifest list); this should always be nil if the primary manifest is not a manifest list.
+// MUST be called after PutManifest (signatures may reference manifest contents).
+func (stub NoSignaturesInitialize) PutSignaturesWithFormat(ctx context.Context, signatures []signature.Signature, instanceDigest *digest.Digest) error {
+ if len(signatures) != 0 {
+ return errors.New(stub.message)
+ }
+ return nil
+}
+
+// SupportsSignatures implements SupportsSignatures() that returns nil.
+// Note that it might be even more useful to return a value dynamically detected based on
+type AlwaysSupportsSignatures struct{}
+
+// SupportsSignatures returns an error (to be displayed to the user) if the destination certainly can't store signatures.
+// Note: It is still possible for PutSignatures to fail if SupportsSignatures returns nil.
+func (stub AlwaysSupportsSignatures) SupportsSignatures(ctx context.Context) error {
+ return nil
+}
diff --git a/internal/imagedestination/stubs/stubs.go b/internal/imagedestination/stubs/stubs.go
new file mode 100644
index 0000000..ab23340
--- /dev/null
+++ b/internal/imagedestination/stubs/stubs.go
@@ -0,0 +1,27 @@
+// Package stubs contains trivial stubs for parts of private.ImageDestination.
+// It can be used from internal/wrapper, so it should not drag in any extra dependencies.
+// Compare with imagedestination/impl, which might require non-trivial implementation work.
+//
+// There are two kinds of stubs:
+//
+// First, there are pure stubs, like ImplementsPutBlobPartial. Those can just be included in an imageDestination
+// implementation:
+//
+// type yourDestination struct {
+// stubs.ImplementsPutBlobPartial
+// …
+// }
+//
+// Second, there are stubs with a constructor, like NoPutBlobPartialInitialize. The Initialize marker
+// means that a constructor must be called:
+//
+// type yourDestination struct {
+// stubs.NoPutBlobPartialInitialize
+// …
+// }
+//
+// dest := &yourDestination{
+// …
+// NoPutBlobPartialInitialize: stubs.NoPutBlobPartial(ref),
+// }
+package stubs
diff --git a/internal/imagedestination/wrapper.go b/internal/imagedestination/wrapper.go
new file mode 100644
index 0000000..17e1870
--- /dev/null
+++ b/internal/imagedestination/wrapper.go
@@ -0,0 +1,96 @@
+package imagedestination
+
+import (
+ "context"
+ "io"
+
+ "github.com/containers/image/v5/internal/imagedestination/stubs"
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// wrapped provides the private.ImageDestination operations
+// for a destination that only implements types.ImageDestination
+type wrapped struct {
+ stubs.NoPutBlobPartialInitialize
+
+ types.ImageDestination
+}
+
+// FromPublic(dest) returns an object that provides the private.ImageDestination API
+//
+// Eventually, we might want to expose this function, and methods of the returned object,
+// as a public API (or rather, a variant that does not include the already-superseded
+// methods of types.ImageDestination, and has added more future-proofing), and more strongly
+// deprecate direct use of types.ImageDestination.
+//
+// NOTE: The returned API MUST NOT be a public interface (it can be either just a struct
+// with public methods, or perhaps a private interface), so that we can add methods
+// without breaking any external implementors of a public interface.
+func FromPublic(dest types.ImageDestination) private.ImageDestination {
+ if dest2, ok := dest.(private.ImageDestination); ok {
+ return dest2
+ }
+ return &wrapped{
+ NoPutBlobPartialInitialize: stubs.NoPutBlobPartial(dest.Reference()),
+
+ ImageDestination: dest,
+ }
+}
+
+// PutBlobWithOptions writes contents of stream and returns data representing the result.
+// inputInfo.Digest can be optionally provided if known; if provided, and stream is read to the end without error, the digest MUST match the stream contents.
+// inputInfo.Size is the expected length of stream, if known.
+// inputInfo.MediaType describes the blob format, if known.
+// WARNING: The contents of stream are being verified on the fly. Until stream.Read() returns io.EOF, the contents of the data SHOULD NOT be available
+// to any other readers for download using the supplied digest.
+// If stream.Read() at any time, ESPECIALLY at end of input, returns an error, PutBlobWithOptions MUST 1) fail, and 2) delete any data stored so far.
+func (w *wrapped) PutBlobWithOptions(ctx context.Context, stream io.Reader, inputInfo types.BlobInfo, options private.PutBlobOptions) (private.UploadedBlob, error) {
+ res, err := w.PutBlob(ctx, stream, inputInfo, options.Cache, options.IsConfig)
+ if err != nil {
+ return private.UploadedBlob{}, err
+ }
+ return private.UploadedBlob{
+ Digest: res.Digest,
+ Size: res.Size,
+ }, nil
+}
+
+// TryReusingBlobWithOptions checks whether the transport already contains, or can efficiently reuse, a blob, and if so, applies it to the current destination
+// (e.g. if the blob is a filesystem layer, this signifies that the changes it describes need to be applied again when composing a filesystem tree).
+// info.Digest must not be empty.
+// If the blob has been successfully reused, returns (true, info, nil).
+// If the transport can not reuse the requested blob, TryReusingBlob returns (false, {}, nil); it returns a non-nil error only on an unexpected failure.
+func (w *wrapped) TryReusingBlobWithOptions(ctx context.Context, info types.BlobInfo, options private.TryReusingBlobOptions) (bool, private.ReusedBlob, error) {
+ if options.RequiredCompression != nil {
+ return false, private.ReusedBlob{}, nil
+ }
+ reused, blob, err := w.TryReusingBlob(ctx, info, options.Cache, options.CanSubstitute)
+ if !reused || err != nil {
+ return reused, private.ReusedBlob{}, err
+ }
+ return true, private.ReusedBlob{
+ Digest: blob.Digest,
+ Size: blob.Size,
+ CompressionOperation: blob.CompressionOperation,
+ CompressionAlgorithm: blob.CompressionAlgorithm,
+ }, nil
+}
+
+// PutSignaturesWithFormat writes a set of signatures to the destination.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to write or overwrite the signatures for
+// (when the primary manifest is a manifest list); this should always be nil if the primary manifest is not a manifest list.
+// MUST be called after PutManifest (signatures may reference manifest contents).
+func (w *wrapped) PutSignaturesWithFormat(ctx context.Context, signatures []signature.Signature, instanceDigest *digest.Digest) error {
+ simpleSigs := [][]byte{}
+ for _, sig := range signatures {
+ simpleSig, ok := sig.(signature.SimpleSigning)
+ if !ok {
+ return signature.UnsupportedFormatError(sig)
+ }
+ simpleSigs = append(simpleSigs, simpleSig.UntrustedSignature())
+ }
+ return w.PutSignatures(ctx, simpleSigs, instanceDigest)
+}
diff --git a/internal/imagesource/impl/compat.go b/internal/imagesource/impl/compat.go
new file mode 100644
index 0000000..7d859c3
--- /dev/null
+++ b/internal/imagesource/impl/compat.go
@@ -0,0 +1,55 @@
+package impl
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/opencontainers/go-digest"
+)
+
+// Compat implements the obsolete parts of types.ImageSource
+// for implementations of private.ImageSource.
+// See AddCompat below.
+type Compat struct {
+ src private.ImageSourceInternalOnly
+}
+
+// AddCompat initializes Compat to implement the obsolete parts of types.ImageSource
+// for implementations of private.ImageSource.
+//
+// Use it like this:
+//
+// type yourSource struct {
+// impl.Compat
+// …
+// }
+//
+// src := &yourSource{…}
+// src.Compat = impl.AddCompat(src)
+func AddCompat(src private.ImageSourceInternalOnly) Compat {
+ return Compat{src}
+}
+
+// GetSignatures returns the image's signatures. It may use a remote (= slow) service.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to retrieve signatures for
+// (when the primary manifest is a manifest list); this never happens if the primary manifest is not a manifest list
+// (e.g. if the source never returns manifest lists).
+func (c *Compat) GetSignatures(ctx context.Context, instanceDigest *digest.Digest) ([][]byte, error) {
+ // Silently ignore signatures with other formats; the caller can’t handle them.
+ // Admittedly callers that want to sync all of the image might want to fail instead; this
+ // way an upgrade of c/image neither breaks them nor adds new functionality.
+ // Alternatively, we could possibly define the old GetSignatures to use the multi-format
+ // signature.Blob representation now, in general, but that could silently break them as well.
+ sigs, err := c.src.GetSignaturesWithFormat(ctx, instanceDigest)
+ if err != nil {
+ return nil, err
+ }
+ simpleSigs := [][]byte{}
+ for _, sig := range sigs {
+ if sig, ok := sig.(signature.SimpleSigning); ok {
+ simpleSigs = append(simpleSigs, sig.UntrustedSignature())
+ }
+ }
+ return simpleSigs, nil
+}
diff --git a/internal/imagesource/impl/layer_infos.go b/internal/imagesource/impl/layer_infos.go
new file mode 100644
index 0000000..d5eae63
--- /dev/null
+++ b/internal/imagesource/impl/layer_infos.go
@@ -0,0 +1,23 @@
+package impl
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// DoesNotAffectLayerInfosForCopy implements LayerInfosForCopy() that returns nothing.
+type DoesNotAffectLayerInfosForCopy struct{}
+
+// LayerInfosForCopy returns either nil (meaning the values in the manifest are fine), or updated values for the layer
+// blobsums that are listed in the image's manifest. If values are returned, they should be used when using GetBlob()
+// to read the image's layers.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to retrieve BlobInfos for
+// (when the primary manifest is a manifest list); this never happens if the primary manifest is not a manifest list
+// (e.g. if the source never returns manifest lists).
+// The Digest field is guaranteed to be provided; Size may be -1.
+// WARNING: The list may contain duplicates, and they are semantically relevant.
+func (stub DoesNotAffectLayerInfosForCopy) LayerInfosForCopy(ctx context.Context, instanceDigest *digest.Digest) ([]types.BlobInfo, error) {
+ return nil, nil
+}
diff --git a/internal/imagesource/impl/properties.go b/internal/imagesource/impl/properties.go
new file mode 100644
index 0000000..73e8c78
--- /dev/null
+++ b/internal/imagesource/impl/properties.go
@@ -0,0 +1,27 @@
+package impl
+
+// Properties collects properties of an ImageSource that are constant throughout its lifetime
+// (but might differ across instances).
+type Properties struct {
+ // HasThreadSafeGetBlob indicates whether GetBlob can be executed concurrently.
+ HasThreadSafeGetBlob bool
+}
+
+// PropertyMethodsInitialize implements parts of private.ImageSource corresponding to Properties.
+type PropertyMethodsInitialize struct {
+ // We need two separate structs, PropertyMethodsInitialize and Properties, because Go prohibits fields and methods with the same name.
+
+ vals Properties
+}
+
+// PropertyMethods creates an PropertyMethodsInitialize for vals.
+func PropertyMethods(vals Properties) PropertyMethodsInitialize {
+ return PropertyMethodsInitialize{
+ vals: vals,
+ }
+}
+
+// HasThreadSafeGetBlob indicates whether GetBlob can be executed concurrently.
+func (o PropertyMethodsInitialize) HasThreadSafeGetBlob() bool {
+ return o.vals.HasThreadSafeGetBlob
+}
diff --git a/internal/imagesource/impl/signatures.go b/internal/imagesource/impl/signatures.go
new file mode 100644
index 0000000..b3a8c7e
--- /dev/null
+++ b/internal/imagesource/impl/signatures.go
@@ -0,0 +1,19 @@
+package impl
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/opencontainers/go-digest"
+)
+
+// NoSignatures implements GetSignatures() that returns nothing.
+type NoSignatures struct{}
+
+// GetSignaturesWithFormat returns the image's signatures. It may use a remote (= slow) service.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to retrieve signatures for
+// (when the primary manifest is a manifest list); this never happens if the primary manifest is not a manifest list
+// (e.g. if the source never returns manifest lists).
+func (stub NoSignatures) GetSignaturesWithFormat(ctx context.Context, instanceDigest *digest.Digest) ([]signature.Signature, error) {
+ return nil, nil
+}
diff --git a/internal/imagesource/stubs/get_blob_at.go b/internal/imagesource/stubs/get_blob_at.go
new file mode 100644
index 0000000..15aee6d
--- /dev/null
+++ b/internal/imagesource/stubs/get_blob_at.go
@@ -0,0 +1,52 @@
+package stubs
+
+import (
+ "context"
+ "fmt"
+ "io"
+
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/types"
+)
+
+// NoGetBlobAtInitialize implements parts of private.ImageSource
+// for transports that don’t support GetBlobAt().
+// See NoGetBlobAt() below.
+type NoGetBlobAtInitialize struct {
+ transportName string
+}
+
+// NoGetBlobAt() creates a NoGetBlobAtInitialize for ref.
+func NoGetBlobAt(ref types.ImageReference) NoGetBlobAtInitialize {
+ return NoGetBlobAtRaw(ref.Transport().Name())
+}
+
+// NoGetBlobAtRaw is the same thing as NoGetBlobAt, but it can be used
+// in situations where no ImageReference is available.
+func NoGetBlobAtRaw(transportName string) NoGetBlobAtInitialize {
+ return NoGetBlobAtInitialize{
+ transportName: transportName,
+ }
+}
+
+// SupportsGetBlobAt() returns true if GetBlobAt (BlobChunkAccessor) is supported.
+func (stub NoGetBlobAtInitialize) SupportsGetBlobAt() bool {
+ return false
+}
+
+// GetBlobAt returns a sequential channel of readers that contain data for the requested
+// blob chunks, and a channel that might get a single error value.
+// The specified chunks must be not overlapping and sorted by their offset.
+// The readers must be fully consumed, in the order they are returned, before blocking
+// to read the next chunk.
+func (stub NoGetBlobAtInitialize) GetBlobAt(ctx context.Context, info types.BlobInfo, chunks []private.ImageSourceChunk) (chan io.ReadCloser, chan error, error) {
+ return nil, nil, fmt.Errorf("internal error: GetBlobAt is not supported by the %q transport", stub.transportName)
+}
+
+// ImplementsGetBlobAt implements SupportsGetBlobAt() that returns true.
+type ImplementsGetBlobAt struct{}
+
+// SupportsGetBlobAt() returns true if GetBlobAt (BlobChunkAccessor) is supported.
+func (stub ImplementsGetBlobAt) SupportsGetBlobAt() bool {
+ return true
+}
diff --git a/internal/imagesource/stubs/stubs.go b/internal/imagesource/stubs/stubs.go
new file mode 100644
index 0000000..cb34539
--- /dev/null
+++ b/internal/imagesource/stubs/stubs.go
@@ -0,0 +1,28 @@
+// Package stubs contains trivial stubs for parts of private.ImageSource.
+// It can be used from internal/wrapper, so it should not drag in any extra dependencies.
+// Compare with imagesource/impl, which might require non-trivial implementation work.
+//
+// There are two kinds of stubs:
+//
+// First, there are pure stubs, like ImplementsGetBlobAt. Those can just be included in an ImageSource
+//
+// implementation:
+//
+// type yourSource struct {
+// stubs.ImplementsGetBlobAt
+// …
+// }
+//
+// Second, there are stubs with a constructor, like NoGetBlobAtInitialize. The Initialize marker
+// means that a constructor must be called:
+//
+// type yourSource struct {
+// stubs.NoGetBlobAtInitialize
+// …
+// }
+//
+// dest := &yourSource{
+// …
+// NoGetBlobAtInitialize: stubs.NoGetBlobAt(ref),
+// }
+package stubs
diff --git a/internal/imagesource/wrapper.go b/internal/imagesource/wrapper.go
new file mode 100644
index 0000000..886b4e8
--- /dev/null
+++ b/internal/imagesource/wrapper.go
@@ -0,0 +1,56 @@
+package imagesource
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/internal/imagesource/stubs"
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// wrapped provides the private.ImageSource operations
+// for a source that only implements types.ImageSource
+type wrapped struct {
+ stubs.NoGetBlobAtInitialize
+
+ types.ImageSource
+}
+
+// FromPublic(src) returns an object that provides the private.ImageSource API
+//
+// Eventually, we might want to expose this function, and methods of the returned object,
+// as a public API (or rather, a variant that does not include the already-superseded
+// methods of types.ImageSource, and has added more future-proofing), and more strongly
+// deprecate direct use of types.ImageSource.
+//
+// NOTE: The returned API MUST NOT be a public interface (it can be either just a struct
+// with public methods, or perhaps a private interface), so that we can add methods
+// without breaking any external implementors of a public interface.
+func FromPublic(src types.ImageSource) private.ImageSource {
+ if src2, ok := src.(private.ImageSource); ok {
+ return src2
+ }
+ return &wrapped{
+ NoGetBlobAtInitialize: stubs.NoGetBlobAt(src.Reference()),
+
+ ImageSource: src,
+ }
+}
+
+// GetSignaturesWithFormat returns the image's signatures. It may use a remote (= slow) service.
+// If instanceDigest is not nil, it contains a digest of the specific manifest instance to retrieve signatures for
+// (when the primary manifest is a manifest list); this never happens if the primary manifest is not a manifest list
+// (e.g. if the source never returns manifest lists).
+func (w *wrapped) GetSignaturesWithFormat(ctx context.Context, instanceDigest *digest.Digest) ([]signature.Signature, error) {
+ sigs, err := w.GetSignatures(ctx, instanceDigest)
+ if err != nil {
+ return nil, err
+ }
+ res := []signature.Signature{}
+ for _, sig := range sigs {
+ res = append(res, signature.SimpleSigningFromBlob(sig))
+ }
+ return res, nil
+}
diff --git a/internal/iolimits/iolimits.go b/internal/iolimits/iolimits.go
new file mode 100644
index 0000000..f17d002
--- /dev/null
+++ b/internal/iolimits/iolimits.go
@@ -0,0 +1,58 @@
+package iolimits
+
+import (
+ "fmt"
+ "io"
+)
+
+// All constants below are intended to be used as limits for `ReadAtMost`. The
+// immediate use-case for limiting the size of in-memory copied data is to
+// protect against OOM DOS attacks as described inCVE-2020-1702. Instead of
+// copying data until running out of memory, we error out after hitting the
+// specified limit.
+const (
+ // megaByte denotes one megabyte and is intended to be used as a limit in
+ // `ReadAtMost`.
+ megaByte = 1 << 20
+ // MaxManifestBodySize is the maximum allowed size of a manifest. The limit
+ // of 4 MB aligns with the one of a Docker registry:
+ // https://github.com/docker/distribution/blob/a8371794149d1d95f1e846744b05c87f2f825e5a/registry/handlers/manifests.go#L30
+ MaxManifestBodySize = 4 * megaByte
+ // MaxAuthTokenBodySize is the maximum allowed size of an auth token.
+ // The limit of 1 MB is considered to be greatly sufficient.
+ MaxAuthTokenBodySize = megaByte
+ // MaxSignatureListBodySize is the maximum allowed size of a signature list.
+ // The limit of 4 MB is considered to be greatly sufficient.
+ MaxSignatureListBodySize = 4 * megaByte
+ // MaxSignatureBodySize is the maximum allowed size of a signature.
+ // The limit of 4 MB is considered to be greatly sufficient.
+ MaxSignatureBodySize = 4 * megaByte
+ // MaxErrorBodySize is the maximum allowed size of an error-response body.
+ // The limit of 1 MB is considered to be greatly sufficient.
+ MaxErrorBodySize = megaByte
+ // MaxConfigBodySize is the maximum allowed size of a config blob.
+ // The limit of 4 MB is considered to be greatly sufficient.
+ MaxConfigBodySize = 4 * megaByte
+ // MaxOpenShiftStatusBody is the maximum allowed size of an OpenShift status body.
+ // The limit of 4 MB is considered to be greatly sufficient.
+ MaxOpenShiftStatusBody = 4 * megaByte
+ // MaxTarFileManifestSize is the maximum allowed size of a (docker save)-like manifest (which may contain multiple images)
+ // The limit of 1 MB is considered to be greatly sufficient.
+ MaxTarFileManifestSize = megaByte
+)
+
+// ReadAtMost reads from reader and errors out if the specified limit (in bytes) is exceeded.
+func ReadAtMost(reader io.Reader, limit int) ([]byte, error) {
+ limitedReader := io.LimitReader(reader, int64(limit+1))
+
+ res, err := io.ReadAll(limitedReader)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(res) > limit {
+ return nil, fmt.Errorf("exceeded maximum allowed size of %d bytes", limit)
+ }
+
+ return res, nil
+}
diff --git a/internal/iolimits/iolimits_test.go b/internal/iolimits/iolimits_test.go
new file mode 100644
index 0000000..630b38c
--- /dev/null
+++ b/internal/iolimits/iolimits_test.go
@@ -0,0 +1,37 @@
+package iolimits
+
+import (
+ "bytes"
+ "math/rand"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadAtMost(t *testing.T) {
+ rng := rand.New(rand.NewSource(0))
+ for _, c := range []struct {
+ input, limit int
+ shouldSucceed bool
+ }{
+ {0, 0, true},
+ {0, 1, true},
+ {1, 0, false},
+ {1, 1, true},
+ {bytes.MinRead*5 - 1, bytes.MinRead * 5, true},
+ {bytes.MinRead * 5, bytes.MinRead * 5, true},
+ {bytes.MinRead*5 + 1, bytes.MinRead * 5, false},
+ } {
+ input := make([]byte, c.input)
+ _, err := rng.Read(input)
+ require.NoError(t, err)
+ result, err := ReadAtMost(bytes.NewReader(input), c.limit)
+ if c.shouldSucceed {
+ assert.NoError(t, err)
+ assert.Equal(t, result, input)
+ } else {
+ assert.Error(t, err)
+ }
+ }
+}
diff --git a/internal/manifest/common.go b/internal/manifest/common.go
new file mode 100644
index 0000000..1f2ccb5
--- /dev/null
+++ b/internal/manifest/common.go
@@ -0,0 +1,72 @@
+package manifest
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// AllowedManifestFields is a bit mask of “essential” manifest fields that ValidateUnambiguousManifestFormat
+// can expect to be present.
+type AllowedManifestFields int
+
+const (
+ AllowedFieldConfig AllowedManifestFields = 1 << iota
+ AllowedFieldFSLayers
+ AllowedFieldHistory
+ AllowedFieldLayers
+ AllowedFieldManifests
+ AllowedFieldFirstUnusedBit // Keep this at the end!
+)
+
+// ValidateUnambiguousManifestFormat rejects manifests (incl. multi-arch) that look like more than
+// one kind we currently recognize, i.e. if they contain any of the known “essential” format fields
+// other than the ones the caller specifically allows.
+// expectedMIMEType is used only for diagnostics.
+// NOTE: The caller should do the non-heuristic validations (e.g. check for any specified format
+// identification/version, or other “magic numbers”) before calling this, to cleanly reject unambiguous
+// data that just isn’t what was expected, as opposed to actually ambiguous data.
+func ValidateUnambiguousManifestFormat(manifest []byte, expectedMIMEType string,
+ allowed AllowedManifestFields) error {
+ if allowed >= AllowedFieldFirstUnusedBit {
+ return fmt.Errorf("internal error: invalid allowedManifestFields value %#v", allowed)
+ }
+ // Use a private type to decode, not just a map[string]any, because we want
+ // to also reject case-insensitive matches (which would be used by Go when really decoding
+ // the manifest).
+ // (It is expected that as manifest formats are added or extended over time, more fields will be added
+ // here.)
+ detectedFields := struct {
+ Config any `json:"config"`
+ FSLayers any `json:"fsLayers"`
+ History any `json:"history"`
+ Layers any `json:"layers"`
+ Manifests any `json:"manifests"`
+ }{}
+ if err := json.Unmarshal(manifest, &detectedFields); err != nil {
+ // The caller was supposed to already validate version numbers, so this should not happen;
+ // let’s not bother with making this error “nice”.
+ return err
+ }
+ unexpected := []string{}
+ // Sadly this isn’t easy to automate in Go, without reflection. So, copy&paste.
+ if detectedFields.Config != nil && (allowed&AllowedFieldConfig) == 0 {
+ unexpected = append(unexpected, "config")
+ }
+ if detectedFields.FSLayers != nil && (allowed&AllowedFieldFSLayers) == 0 {
+ unexpected = append(unexpected, "fsLayers")
+ }
+ if detectedFields.History != nil && (allowed&AllowedFieldHistory) == 0 {
+ unexpected = append(unexpected, "history")
+ }
+ if detectedFields.Layers != nil && (allowed&AllowedFieldLayers) == 0 {
+ unexpected = append(unexpected, "layers")
+ }
+ if detectedFields.Manifests != nil && (allowed&AllowedFieldManifests) == 0 {
+ unexpected = append(unexpected, "manifests")
+ }
+ if len(unexpected) != 0 {
+ return fmt.Errorf(`rejecting ambiguous manifest, unexpected fields %#v in supposedly %s`,
+ unexpected, expectedMIMEType)
+ }
+ return nil
+}
diff --git a/internal/manifest/common_test.go b/internal/manifest/common_test.go
new file mode 100644
index 0000000..553cc36
--- /dev/null
+++ b/internal/manifest/common_test.go
@@ -0,0 +1,91 @@
+package manifest
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateUnambiguousManifestFormat(t *testing.T) {
+ const allAllowedFields = AllowedFieldFirstUnusedBit - 1
+ const mt = "text/plain" // Just some MIME type that shows up in error messages
+
+ type test struct {
+ manifest string
+ allowed AllowedManifestFields
+ }
+
+ // Smoke tests: Success
+ for _, c := range []test{
+ {"{}", allAllowedFields},
+ {"{}", 0},
+ } {
+ err := ValidateUnambiguousManifestFormat([]byte(c.manifest), mt, c.allowed)
+ assert.NoError(t, err, c)
+ }
+ // Smoke tests: Failure
+ for _, c := range []test{
+ {"{}", AllowedFieldFirstUnusedBit}, // Invalid "allowed"
+ {"@", allAllowedFields}, // Invalid JSON
+ } {
+ err := ValidateUnambiguousManifestFormat([]byte(c.manifest), mt, c.allowed)
+ assert.Error(t, err, c)
+ }
+
+ fields := map[AllowedManifestFields]string{
+ AllowedFieldConfig: "config",
+ AllowedFieldFSLayers: "fsLayers",
+ AllowedFieldHistory: "history",
+ AllowedFieldLayers: "layers",
+ AllowedFieldManifests: "manifests",
+ }
+ // Ensure this test covers all defined AllowedManifestFields values
+ allFields := AllowedManifestFields(0)
+ for k := range fields {
+ allFields |= k
+ }
+ assert.Equal(t, allAllowedFields, allFields)
+
+ // Every single field is allowed by its bit, and rejected by any other bit
+ for bit, fieldName := range fields {
+ json := []byte(fmt.Sprintf(`{"%s":[]}`, fieldName))
+ err := ValidateUnambiguousManifestFormat(json, mt, bit)
+ assert.NoError(t, err, fieldName)
+ err = ValidateUnambiguousManifestFormat(json, mt, allAllowedFields^bit)
+ assert.Error(t, err, fieldName)
+ }
+}
+
+// Test that parser() rejects all of the provided manifest fixtures.
+// Intended to help test manifest parsers' detection of schema mismatches.
+func testManifestFixturesAreRejected(t *testing.T, parser func([]byte) error, fixtures []string) {
+ for _, fixture := range fixtures {
+ manifest, err := os.ReadFile(filepath.Join("testdata", fixture))
+ require.NoError(t, err, fixture)
+ err = parser(manifest)
+ assert.Error(t, err, fixture)
+ }
+}
+
+// Test that parser() rejects validManifest with an added top-level field with any of the provided field names.
+// Intended to help test callers of validateUnambiguousManifestFormat.
+func testValidManifestWithExtraFieldsIsRejected(t *testing.T, parser func([]byte) error,
+ validManifest []byte, fields []string) {
+ for _, field := range fields {
+ // end (the final '}') is not always at len(validManifest)-1 because the manifest can end with
+ // white space.
+ end := bytes.LastIndexByte(validManifest, '}')
+ require.NotEqual(t, end, -1)
+ updatedManifest := []byte(string(validManifest[:end]) +
+ fmt.Sprintf(`,"%s":[]}`, field))
+ err := parser(updatedManifest)
+ // Make sure it is the error from validateUnambiguousManifestFormat, not something that
+ // went wrong with creating updatedManifest.
+ assert.ErrorContains(t, err, "rejecting ambiguous manifest", field)
+ }
+}
diff --git a/internal/manifest/docker_schema2.go b/internal/manifest/docker_schema2.go
new file mode 100644
index 0000000..68d0796
--- /dev/null
+++ b/internal/manifest/docker_schema2.go
@@ -0,0 +1,15 @@
+package manifest
+
+import (
+ "github.com/opencontainers/go-digest"
+)
+
+// Schema2Descriptor is a “descriptor” in docker/distribution schema 2.
+//
+// This is publicly visible as c/image/manifest.Schema2Descriptor.
+type Schema2Descriptor struct {
+ MediaType string `json:"mediaType"`
+ Size int64 `json:"size"`
+ Digest digest.Digest `json:"digest"`
+ URLs []string `json:"urls,omitempty"`
+}
diff --git a/internal/manifest/docker_schema2_list.go b/internal/manifest/docker_schema2_list.go
new file mode 100644
index 0000000..7ce5bb0
--- /dev/null
+++ b/internal/manifest/docker_schema2_list.go
@@ -0,0 +1,314 @@
+package manifest
+
+import (
+ "encoding/json"
+ "fmt"
+
+ platform "github.com/containers/image/v5/internal/pkg/platform"
+ compression "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "golang.org/x/exp/slices"
+)
+
+// Schema2PlatformSpec describes the platform which a particular manifest is
+// specialized for.
+// This is publicly visible as c/image/manifest.Schema2PlatformSpec.
+type Schema2PlatformSpec struct {
+ Architecture string `json:"architecture"`
+ OS string `json:"os"`
+ OSVersion string `json:"os.version,omitempty"`
+ OSFeatures []string `json:"os.features,omitempty"`
+ Variant string `json:"variant,omitempty"`
+ Features []string `json:"features,omitempty"` // removed in OCI
+}
+
+// Schema2ManifestDescriptor references a platform-specific manifest.
+// This is publicly visible as c/image/manifest.Schema2ManifestDescriptor.
+type Schema2ManifestDescriptor struct {
+ Schema2Descriptor
+ Platform Schema2PlatformSpec `json:"platform"`
+}
+
+// Schema2ListPublic is a list of platform-specific manifests.
+// This is publicly visible as c/image/manifest.Schema2List.
+// Internal users should usually use Schema2List instead.
+type Schema2ListPublic struct {
+ SchemaVersion int `json:"schemaVersion"`
+ MediaType string `json:"mediaType"`
+ Manifests []Schema2ManifestDescriptor `json:"manifests"`
+}
+
+// MIMEType returns the MIME type of this particular manifest list.
+func (list *Schema2ListPublic) MIMEType() string {
+ return list.MediaType
+}
+
+// Instances returns a slice of digests of the manifests that this list knows of.
+func (list *Schema2ListPublic) Instances() []digest.Digest {
+ results := make([]digest.Digest, len(list.Manifests))
+ for i, m := range list.Manifests {
+ results[i] = m.Digest
+ }
+ return results
+}
+
+// Instance returns the ListUpdate of a particular instance in the list.
+func (list *Schema2ListPublic) Instance(instanceDigest digest.Digest) (ListUpdate, error) {
+ for _, manifest := range list.Manifests {
+ if manifest.Digest == instanceDigest {
+ ret := ListUpdate{
+ Digest: manifest.Digest,
+ Size: manifest.Size,
+ MediaType: manifest.MediaType,
+ }
+ ret.ReadOnly.CompressionAlgorithmNames = []string{compression.GzipAlgorithmName}
+ platform := ociPlatformFromSchema2PlatformSpec(manifest.Platform)
+ ret.ReadOnly.Platform = &platform
+ return ret, nil
+ }
+ }
+ return ListUpdate{}, fmt.Errorf("unable to find instance %s passed to Schema2List.Instances", instanceDigest)
+}
+
+// UpdateInstances updates the sizes, digests, and media types of the manifests
+// which the list catalogs.
+func (index *Schema2ListPublic) UpdateInstances(updates []ListUpdate) error {
+ editInstances := []ListEdit{}
+ for i, instance := range updates {
+ editInstances = append(editInstances, ListEdit{
+ UpdateOldDigest: index.Manifests[i].Digest,
+ UpdateDigest: instance.Digest,
+ UpdateSize: instance.Size,
+ UpdateMediaType: instance.MediaType,
+ ListOperation: ListOpUpdate})
+ }
+ return index.editInstances(editInstances)
+}
+
+func (index *Schema2ListPublic) editInstances(editInstances []ListEdit) error {
+ addedEntries := []Schema2ManifestDescriptor{}
+ for i, editInstance := range editInstances {
+ switch editInstance.ListOperation {
+ case ListOpUpdate:
+ if err := editInstance.UpdateOldDigest.Validate(); err != nil {
+ return fmt.Errorf("Schema2List.EditInstances: Attempting to update %s which is an invalid digest: %w", editInstance.UpdateOldDigest, err)
+ }
+ if err := editInstance.UpdateDigest.Validate(); err != nil {
+ return fmt.Errorf("Schema2List.EditInstances: Modified digest %s is an invalid digest: %w", editInstance.UpdateDigest, err)
+ }
+ targetIndex := slices.IndexFunc(index.Manifests, func(m Schema2ManifestDescriptor) bool {
+ return m.Digest == editInstance.UpdateOldDigest
+ })
+ if targetIndex == -1 {
+ return fmt.Errorf("Schema2List.EditInstances: digest %s not found", editInstance.UpdateOldDigest)
+ }
+ index.Manifests[targetIndex].Digest = editInstance.UpdateDigest
+ if editInstance.UpdateSize < 0 {
+ return fmt.Errorf("update %d of %d passed to Schema2List.UpdateInstances had an invalid size (%d)", i+1, len(editInstances), editInstance.UpdateSize)
+ }
+ index.Manifests[targetIndex].Size = editInstance.UpdateSize
+ if editInstance.UpdateMediaType == "" {
+ return fmt.Errorf("update %d of %d passed to Schema2List.UpdateInstances had no media type (was %q)", i+1, len(editInstances), index.Manifests[i].MediaType)
+ }
+ index.Manifests[targetIndex].MediaType = editInstance.UpdateMediaType
+ case ListOpAdd:
+ if editInstance.AddPlatform == nil {
+ // Should we create a struct with empty fields instead?
+ // Right now ListOpAdd is only called when an instance with the same platform value
+ // already exists in the manifest, so this should not be reached in practice.
+ return fmt.Errorf("adding a schema2 list instance with no platform specified is not supported")
+ }
+ addedEntries = append(addedEntries, Schema2ManifestDescriptor{
+ Schema2Descriptor{
+ Digest: editInstance.AddDigest,
+ Size: editInstance.AddSize,
+ MediaType: editInstance.AddMediaType,
+ },
+ schema2PlatformSpecFromOCIPlatform(*editInstance.AddPlatform),
+ })
+ default:
+ return fmt.Errorf("internal error: invalid operation: %d", editInstance.ListOperation)
+ }
+ }
+ if len(addedEntries) != 0 {
+ // slices.Clone() here to ensure a private backing array;
+ // an external caller could have manually created Schema2ListPublic with a slice with extra capacity.
+ index.Manifests = append(slices.Clone(index.Manifests), addedEntries...)
+ }
+ return nil
+}
+
+func (index *Schema2List) EditInstances(editInstances []ListEdit) error {
+ return index.editInstances(editInstances)
+}
+
+func (list *Schema2ListPublic) ChooseInstanceByCompression(ctx *types.SystemContext, preferGzip types.OptionalBool) (digest.Digest, error) {
+ // ChooseInstanceByCompression is same as ChooseInstance for schema2 manifest list.
+ return list.ChooseInstance(ctx)
+}
+
+// ChooseInstance parses blob as a schema2 manifest list, and returns the digest
+// of the image which is appropriate for the current environment.
+func (list *Schema2ListPublic) ChooseInstance(ctx *types.SystemContext) (digest.Digest, error) {
+ wantedPlatforms, err := platform.WantedPlatforms(ctx)
+ if err != nil {
+ return "", fmt.Errorf("getting platform information %#v: %w", ctx, err)
+ }
+ for _, wantedPlatform := range wantedPlatforms {
+ for _, d := range list.Manifests {
+ imagePlatform := ociPlatformFromSchema2PlatformSpec(d.Platform)
+ if platform.MatchesPlatform(imagePlatform, wantedPlatform) {
+ return d.Digest, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("no image found in manifest list for architecture %s, variant %q, OS %s", wantedPlatforms[0].Architecture, wantedPlatforms[0].Variant, wantedPlatforms[0].OS)
+}
+
+// Serialize returns the list in a blob format.
+// NOTE: Serialize() does not in general reproduce the original blob if this object was loaded from one, even if no modifications were made!
+func (list *Schema2ListPublic) Serialize() ([]byte, error) {
+ buf, err := json.Marshal(list)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling Schema2List %#v: %w", list, err)
+ }
+ return buf, nil
+}
+
+// Schema2ListPublicFromComponents creates a Schema2 manifest list instance from the
+// supplied data.
+// This is publicly visible as c/image/manifest.Schema2ListFromComponents.
+func Schema2ListPublicFromComponents(components []Schema2ManifestDescriptor) *Schema2ListPublic {
+ list := Schema2ListPublic{
+ SchemaVersion: 2,
+ MediaType: DockerV2ListMediaType,
+ Manifests: make([]Schema2ManifestDescriptor, len(components)),
+ }
+ for i, component := range components {
+ m := Schema2ManifestDescriptor{
+ Schema2Descriptor{
+ MediaType: component.MediaType,
+ Size: component.Size,
+ Digest: component.Digest,
+ URLs: slices.Clone(component.URLs),
+ },
+ Schema2PlatformSpec{
+ Architecture: component.Platform.Architecture,
+ OS: component.Platform.OS,
+ OSVersion: component.Platform.OSVersion,
+ OSFeatures: slices.Clone(component.Platform.OSFeatures),
+ Variant: component.Platform.Variant,
+ Features: slices.Clone(component.Platform.Features),
+ },
+ }
+ list.Manifests[i] = m
+ }
+ return &list
+}
+
+// Schema2ListPublicClone creates a deep copy of the passed-in list.
+// This is publicly visible as c/image/manifest.Schema2ListClone.
+func Schema2ListPublicClone(list *Schema2ListPublic) *Schema2ListPublic {
+ return Schema2ListPublicFromComponents(list.Manifests)
+}
+
+// ToOCI1Index returns the list encoded as an OCI1 index.
+func (list *Schema2ListPublic) ToOCI1Index() (*OCI1IndexPublic, error) {
+ components := make([]imgspecv1.Descriptor, 0, len(list.Manifests))
+ for _, manifest := range list.Manifests {
+ platform := ociPlatformFromSchema2PlatformSpec(manifest.Platform)
+ components = append(components, imgspecv1.Descriptor{
+ MediaType: manifest.MediaType,
+ Size: manifest.Size,
+ Digest: manifest.Digest,
+ URLs: slices.Clone(manifest.URLs),
+ Platform: &platform,
+ })
+ }
+ oci := OCI1IndexPublicFromComponents(components, nil)
+ return oci, nil
+}
+
+// ToSchema2List returns the list encoded as a Schema2 list.
+func (list *Schema2ListPublic) ToSchema2List() (*Schema2ListPublic, error) {
+ return Schema2ListPublicClone(list), nil
+}
+
+// Schema2ListPublicFromManifest creates a Schema2 manifest list instance from marshalled
+// JSON, presumably generated by encoding a Schema2 manifest list.
+// This is publicly visible as c/image/manifest.Schema2ListFromManifest.
+func Schema2ListPublicFromManifest(manifest []byte) (*Schema2ListPublic, error) {
+ list := Schema2ListPublic{
+ Manifests: []Schema2ManifestDescriptor{},
+ }
+ if err := json.Unmarshal(manifest, &list); err != nil {
+ return nil, fmt.Errorf("unmarshaling Schema2List %q: %w", string(manifest), err)
+ }
+ if err := ValidateUnambiguousManifestFormat(manifest, DockerV2ListMediaType,
+ AllowedFieldManifests); err != nil {
+ return nil, err
+ }
+ return &list, nil
+}
+
+// Clone returns a deep copy of this list and its contents.
+func (list *Schema2ListPublic) Clone() ListPublic {
+ return Schema2ListPublicClone(list)
+}
+
+// ConvertToMIMEType converts the passed-in manifest list to a manifest
+// list of the specified type.
+func (list *Schema2ListPublic) ConvertToMIMEType(manifestMIMEType string) (ListPublic, error) {
+ switch normalized := NormalizedMIMEType(manifestMIMEType); normalized {
+ case DockerV2ListMediaType:
+ return list.Clone(), nil
+ case imgspecv1.MediaTypeImageIndex:
+ return list.ToOCI1Index()
+ case DockerV2Schema1MediaType, DockerV2Schema1SignedMediaType, imgspecv1.MediaTypeImageManifest, DockerV2Schema2MediaType:
+ return nil, fmt.Errorf("Can not convert manifest list to MIME type %q, which is not a list type", manifestMIMEType)
+ default:
+ // Note that this may not be reachable, NormalizedMIMEType has a default for unknown values.
+ return nil, fmt.Errorf("Unimplemented manifest list MIME type %s", manifestMIMEType)
+ }
+}
+
+// Schema2List is a list of platform-specific manifests.
+type Schema2List struct {
+ Schema2ListPublic
+}
+
+func schema2ListFromPublic(public *Schema2ListPublic) *Schema2List {
+ return &Schema2List{*public}
+}
+
+func (index *Schema2List) CloneInternal() List {
+ return schema2ListFromPublic(Schema2ListPublicClone(&index.Schema2ListPublic))
+}
+
+func (index *Schema2List) Clone() ListPublic {
+ return index.CloneInternal()
+}
+
+// Schema2ListFromManifest creates a Schema2 manifest list instance from marshalled
+// JSON, presumably generated by encoding a Schema2 manifest list.
+func Schema2ListFromManifest(manifest []byte) (*Schema2List, error) {
+ public, err := Schema2ListPublicFromManifest(manifest)
+ if err != nil {
+ return nil, err
+ }
+ return schema2ListFromPublic(public), nil
+}
+
+// ociPlatformFromSchema2PlatformSpec converts a schema2 platform p to the OCI struccture.
+func ociPlatformFromSchema2PlatformSpec(p Schema2PlatformSpec) imgspecv1.Platform {
+ return imgspecv1.Platform{
+ Architecture: p.Architecture,
+ OS: p.OS,
+ OSVersion: p.OSVersion,
+ OSFeatures: slices.Clone(p.OSFeatures),
+ Variant: p.Variant,
+ // Features is not supported in OCI, and discarded.
+ }
+}
diff --git a/internal/manifest/docker_schema2_list_test.go b/internal/manifest/docker_schema2_list_test.go
new file mode 100644
index 0000000..2824cf0
--- /dev/null
+++ b/internal/manifest/docker_schema2_list_test.go
@@ -0,0 +1,109 @@
+package manifest
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ compressionTypes "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/slices"
+)
+
+func TestSchema2ListPublicFromManifest(t *testing.T) {
+ validManifest, err := os.ReadFile(filepath.Join("testdata", "v2list.manifest.json"))
+ require.NoError(t, err)
+
+ parser := func(m []byte) error {
+ _, err := Schema2ListPublicFromManifest(m)
+ return err
+ }
+ // Schema mismatch is rejected
+ testManifestFixturesAreRejected(t, parser, []string{
+ "schema2-to-schema1-by-docker.json",
+ "v2s2.manifest.json",
+ "ociv1.manifest.json",
+ // Not "ociv1.image.index.json" yet, without validating mediaType the two are too similar to tell the difference.
+ })
+ // Extra fields are rejected
+ testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
+}
+
+func TestSchema2ListEditInstances(t *testing.T) {
+ validManifest, err := os.ReadFile(filepath.Join("testdata", "v2list.manifest.json"))
+ require.NoError(t, err)
+ list, err := ListFromBlob(validManifest, GuessMIMEType(validManifest))
+ require.NoError(t, err)
+
+ expectedDigests := list.Instances()
+ editInstances := []ListEdit{}
+ editInstances = append(editInstances, ListEdit{
+ UpdateOldDigest: list.Instances()[0],
+ UpdateDigest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ UpdateSize: 32,
+ UpdateMediaType: "something",
+ ListOperation: ListOpUpdate})
+ err = list.EditInstances(editInstances)
+ require.NoError(t, err)
+
+ expectedDigests[0] = editInstances[0].UpdateDigest
+ // order of old elements must remain same.
+ assert.Equal(t, list.Instances(), expectedDigests)
+
+ instance, err := list.Instance(list.Instances()[0])
+ require.NoError(t, err)
+ assert.Equal(t, "something", instance.MediaType)
+ assert.Equal(t, int64(32), instance.Size)
+ // platform must match with instance platform set in `v2list.manifest.json` for the first instance
+ assert.Equal(t, &imgspecv1.Platform{Architecture: "ppc64le", OS: "linux", OSVersion: "", OSFeatures: []string(nil), Variant: ""}, instance.ReadOnly.Platform)
+ assert.Equal(t, []string{compressionTypes.GzipAlgorithmName}, instance.ReadOnly.CompressionAlgorithmNames)
+
+ // Create a fresh list
+ list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
+ require.NoError(t, err)
+ originalListOrder := list.Instances()
+
+ editInstances = []ListEdit{}
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ ListOperation: ListOpAdd})
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ ListOperation: ListOpAdd})
+ err = list.EditInstances(editInstances)
+ require.NoError(t, err)
+
+ // Verify new elements are added to the end of old list
+ assert.Equal(t, append(slices.Clone(originalListOrder),
+ digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
+ digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
+ ), list.Instances())
+}
+
+func TestSchema2ListFromManifest(t *testing.T) {
+ validManifest, err := os.ReadFile(filepath.Join("testdata", "v2list.manifest.json"))
+ require.NoError(t, err)
+
+ parser := func(m []byte) error {
+ _, err := Schema2ListFromManifest(m)
+ return err
+ }
+ // Schema mismatch is rejected
+ testManifestFixturesAreRejected(t, parser, []string{
+ "schema2-to-schema1-by-docker.json",
+ "v2s2.manifest.json",
+ "ociv1.manifest.json",
+ // Not "ociv1.image.index.json" yet, without validating mediaType the two are too similar to tell the difference.
+ })
+ // Extra fields are rejected
+ testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
+}
diff --git a/internal/manifest/errors.go b/internal/manifest/errors.go
new file mode 100644
index 0000000..6c8e233
--- /dev/null
+++ b/internal/manifest/errors.go
@@ -0,0 +1,56 @@
+package manifest
+
+import (
+ "fmt"
+
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// FIXME: This is a duplicate of c/image/manifestDockerV2Schema2ConfigMediaType.
+// Deduplicate that, depending on outcome of https://github.com/containers/image/pull/1791 .
+const dockerV2Schema2ConfigMediaType = "application/vnd.docker.container.image.v1+json"
+
+// NonImageArtifactError (detected via errors.As) is used when asking for an image-specific operation
+// on an object which is not a “container image” in the standard sense (e.g. an OCI artifact)
+//
+// This is publicly visible as c/image/manifest.NonImageArtifactError (but we don’t provide a public constructor)
+type NonImageArtifactError struct {
+ // Callers should not be blindly calling image-specific operations and only checking MIME types
+ // on failure; if they care about the artifact type, they should check before using it.
+ // If they blindly assume an image, they don’t really need this value; just a type check
+ // is sufficient for basic "we can only pull images" UI.
+ //
+ // Also, there are fairly widespread “artifacts” which nevertheless use imgspecv1.MediaTypeImageConfig,
+ // e.g. https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md , which could cause the callers
+ // to complain about a non-image artifact with the correct MIME type; we should probably add some other kind of
+ // type discrimination, _and_ somehow make it available in the API, if we expect API callers to make decisions
+ // based on that kind of data.
+ //
+ // So, let’s not expose this until a specific need is identified.
+ mimeType string
+}
+
+// NewNonImageArtifactError returns a NonImageArtifactError about an artifact manifest.
+//
+// This is typically called if manifest.Config.MediaType != imgspecv1.MediaTypeImageConfig .
+func NewNonImageArtifactError(manifest *imgspecv1.Manifest) error {
+ // Callers decide based on manifest.Config.MediaType that this is not an image;
+ // in that case manifest.ArtifactType can be optionally defined, and if it is, it is typically
+ // more relevant because config may be ~absent with imgspecv1.MediaTypeEmptyJSON.
+ //
+ // If ArtifactType and Config.MediaType are both defined and non-trivial, presumably
+ // ArtifactType is the “top-level” one, although that’s not defined by the spec.
+ mimeType := manifest.ArtifactType
+ if mimeType == "" {
+ mimeType = manifest.Config.MediaType
+ }
+ return NonImageArtifactError{mimeType: mimeType}
+}
+
+func (e NonImageArtifactError) Error() string {
+ // Special-case these invalid mixed images, which show up from time to time:
+ if e.mimeType == dockerV2Schema2ConfigMediaType {
+ return fmt.Sprintf("invalid mixed OCI image with Docker v2s2 config (%q)", e.mimeType)
+ }
+ return fmt.Sprintf("unsupported image-specific operation on artifact with type %q", e.mimeType)
+}
diff --git a/internal/manifest/list.go b/internal/manifest/list.go
new file mode 100644
index 0000000..189f1a7
--- /dev/null
+++ b/internal/manifest/list.go
@@ -0,0 +1,131 @@
+package manifest
+
+import (
+ "fmt"
+
+ compression "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ digest "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// ListPublic is a subset of List which is a part of the public API;
+// so no methods can be added, removed or changed.
+//
+// Internal users should usually use List instead.
+type ListPublic interface {
+ // MIMEType returns the MIME type of this particular manifest list.
+ MIMEType() string
+
+ // Instances returns a list of the manifests that this list knows of, other than its own.
+ Instances() []digest.Digest
+
+ // Update information about the list's instances. The length of the passed-in slice must
+ // match the length of the list of instances which the list already contains, and every field
+ // must be specified.
+ UpdateInstances([]ListUpdate) error
+
+ // Instance returns the size and MIME type of a particular instance in the list.
+ Instance(digest.Digest) (ListUpdate, error)
+
+ // ChooseInstance selects which manifest is most appropriate for the platform described by the
+ // SystemContext, or for the current platform if the SystemContext doesn't specify any details.
+ ChooseInstance(ctx *types.SystemContext) (digest.Digest, error)
+
+ // Serialize returns the list in a blob format.
+ // NOTE: Serialize() does not in general reproduce the original blob if this object was loaded
+ // from, even if no modifications were made!
+ Serialize() ([]byte, error)
+
+ // ConvertToMIMEType returns the list rebuilt to the specified MIME type, or an error.
+ ConvertToMIMEType(mimeType string) (ListPublic, error)
+
+ // Clone returns a deep copy of this list and its contents.
+ Clone() ListPublic
+}
+
+// List is an interface for parsing, modifying lists of image manifests.
+// Callers can either use this abstract interface without understanding the details of the formats,
+// or instantiate a specific implementation (e.g. manifest.OCI1Index) and access the public members
+// directly.
+type List interface {
+ ListPublic
+ // CloneInternal returns a deep copy of this list and its contents.
+ CloneInternal() List
+ // ChooseInstanceInstanceByCompression selects which manifest is most appropriate for the platform and compression described by the
+ // SystemContext ( or for the current platform if the SystemContext doesn't specify any detail ) and preferGzip for compression which
+ // when configured to OptionalBoolTrue and chooses best available compression when it is OptionalBoolFalse or left OptionalBoolUndefined.
+ ChooseInstanceByCompression(ctx *types.SystemContext, preferGzip types.OptionalBool) (digest.Digest, error)
+ // Edit information about the list's instances. Contains Slice of ListEdit where each element
+ // is responsible for either Modifying or Adding a new instance to the Manifest. Operation is
+ // selected on the basis of configured ListOperation field.
+ EditInstances([]ListEdit) error
+}
+
+// ListUpdate includes the fields which a List's UpdateInstances() method will modify.
+// This is publicly visible as c/image/manifest.ListUpdate.
+type ListUpdate struct {
+ Digest digest.Digest
+ Size int64
+ MediaType string
+ // ReadOnly fields: may be set by Instance(), ignored by UpdateInstance()
+ ReadOnly struct {
+ Platform *imgspecv1.Platform
+ Annotations map[string]string
+ CompressionAlgorithmNames []string
+ }
+}
+
+type ListOp int
+
+const (
+ listOpInvalid ListOp = iota
+ ListOpAdd
+ ListOpUpdate
+)
+
+// ListEdit includes the fields which a List's EditInstances() method will modify.
+type ListEdit struct {
+ ListOperation ListOp
+
+ // if Op == ListEditUpdate (basically the previous UpdateInstances). All fields must be set.
+ UpdateOldDigest digest.Digest
+ UpdateDigest digest.Digest
+ UpdateSize int64
+ UpdateMediaType string
+ UpdateAffectAnnotations bool
+ UpdateAnnotations map[string]string
+ UpdateCompressionAlgorithms []compression.Algorithm
+
+ // If Op = ListEditAdd. All fields must be set.
+ AddDigest digest.Digest
+ AddSize int64
+ AddMediaType string
+ AddPlatform *imgspecv1.Platform
+ AddAnnotations map[string]string
+ AddCompressionAlgorithms []compression.Algorithm
+}
+
+// ListPublicFromBlob parses a list of manifests.
+// This is publicly visible as c/image/manifest.ListFromBlob.
+func ListPublicFromBlob(manifest []byte, manifestMIMEType string) (ListPublic, error) {
+ list, err := ListFromBlob(manifest, manifestMIMEType)
+ if err != nil {
+ return nil, err
+ }
+ return list, nil
+}
+
+// ListFromBlob parses a list of manifests.
+func ListFromBlob(manifest []byte, manifestMIMEType string) (List, error) {
+ normalized := NormalizedMIMEType(manifestMIMEType)
+ switch normalized {
+ case DockerV2ListMediaType:
+ return Schema2ListFromManifest(manifest)
+ case imgspecv1.MediaTypeImageIndex:
+ return OCI1IndexFromManifest(manifest)
+ case DockerV2Schema1MediaType, DockerV2Schema1SignedMediaType, imgspecv1.MediaTypeImageManifest, DockerV2Schema2MediaType:
+ return nil, fmt.Errorf("Treating single images as manifest lists is not implemented")
+ }
+ return nil, fmt.Errorf("Unimplemented manifest list MIME type %s (normalized as %s)", manifestMIMEType, normalized)
+}
diff --git a/internal/manifest/list_test.go b/internal/manifest/list_test.go
new file mode 100644
index 0000000..2f1479b
--- /dev/null
+++ b/internal/manifest/list_test.go
@@ -0,0 +1,161 @@
+package manifest
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func pare(m List) {
+ if impl, ok := m.(*OCI1Index); ok {
+ impl.Annotations = nil
+ }
+ if impl, ok := m.(*Schema2List); ok {
+ for i := range impl.Manifests {
+ impl.Manifests[i].Platform.Features = nil
+ }
+ }
+}
+
+func TestParseLists(t *testing.T) {
+ cases := []struct {
+ path string
+ mimeType string
+ }{
+ {"ociv1.image.index.json", imgspecv1.MediaTypeImageIndex},
+ {"v2list.manifest.json", DockerV2ListMediaType},
+ }
+ for _, c := range cases {
+ manifest, err := os.ReadFile(filepath.Join("testdata", c.path))
+ require.NoError(t, err, "error reading file %q", filepath.Join("testdata", c.path))
+ assert.Equal(t, GuessMIMEType(manifest), c.mimeType)
+
+ // c/image/manifest.TestParseLists verifies that FromBlob refuses to parse the manifest list
+
+ m, err := ListFromBlob(manifest, c.mimeType)
+ require.NoError(t, err, "manifest list %q should parse as list types", c.path)
+ assert.Equal(t, m.MIMEType(), c.mimeType, "manifest %q is not of the expected MIME type", c.path)
+
+ clone := m.Clone()
+ assert.Equal(t, clone, m, "manifest %q is missing some fields after being cloned", c.path)
+
+ pare(m)
+
+ index, err := m.ConvertToMIMEType(imgspecv1.MediaTypeImageIndex)
+ require.NoError(t, err, "error converting %q to an OCI1Index", c.path)
+
+ list, err := m.ConvertToMIMEType(DockerV2ListMediaType)
+ require.NoError(t, err, "error converting %q to an Schema2List", c.path)
+
+ index2, err := list.ConvertToMIMEType(imgspecv1.MediaTypeImageIndex)
+ require.NoError(t, err)
+ assert.Equal(t, index, index2, "index %q lost data in conversion", c.path)
+
+ list2, err := index.ConvertToMIMEType(DockerV2ListMediaType)
+ require.NoError(t, err)
+ assert.Equal(t, list, list2, "list %q lost data in conversion", c.path)
+ }
+}
+
+func TestChooseInstance(t *testing.T) {
+ type expectedMatch struct {
+ arch, variant string
+ instanceDigest digest.Digest
+ }
+ chooseInstanceCalls := []func(sys *types.SystemContext, rawManifest []byte) (digest.Digest, error){
+ func(sys *types.SystemContext, rawManifest []byte) (digest.Digest, error) {
+ list, err := ListPublicFromBlob(rawManifest, GuessMIMEType(rawManifest))
+ require.NoError(t, err)
+ return list.ChooseInstance(sys)
+ },
+ // Gzip preference true.
+ func(sys *types.SystemContext, rawManifest []byte) (digest.Digest, error) {
+ list, err := ListFromBlob(rawManifest, GuessMIMEType(rawManifest))
+ require.NoError(t, err)
+ return list.ChooseInstanceByCompression(sys, types.OptionalBoolTrue)
+ },
+ // Gzip preference false.
+ func(sys *types.SystemContext, rawManifest []byte) (digest.Digest, error) {
+ list, err := ListFromBlob(rawManifest, GuessMIMEType(rawManifest))
+ require.NoError(t, err)
+ return list.ChooseInstanceByCompression(sys, types.OptionalBoolFalse)
+ },
+ func(sys *types.SystemContext, rawManifest []byte) (digest.Digest, error) {
+ list, err := ListFromBlob(rawManifest, GuessMIMEType(rawManifest))
+ require.NoError(t, err)
+ return list.ChooseInstanceByCompression(sys, types.OptionalBoolUndefined)
+ },
+ }
+ for _, manifestList := range []struct {
+ listFile string
+ matchedInstances []expectedMatch
+ unmatchedInstances []string
+ }{
+ {
+ listFile: "schema2list.json",
+ matchedInstances: []expectedMatch{
+ {"amd64", "", "sha256:030fcb92e1487b18c974784dcc110a93147c9fc402188370fbfd17efabffc6af"},
+ {"s390x", "", "sha256:e5aa1b0a24620228b75382997a0977f609b3ca3a95533dafdef84c74cc8df642"},
+ {"arm", "v7", "sha256:b5dbad4bdb4444d919294afe49a095c23e86782f98cdf0aa286198ddb814b50b"},
+ {"arm64", "", "sha256:dc472a59fb006797aa2a6bfb54cc9c57959bb0a6d11fadaa608df8c16dea39cf"},
+ },
+ unmatchedInstances: []string{
+ "unmatched",
+ },
+ },
+ { // Focus on ARM variant field testing
+ listFile: "schema2list-variants.json",
+ matchedInstances: []expectedMatch{
+ {"amd64", "", "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610"},
+ {"arm", "v7", "sha256:f365626a556e58189fc21d099fc64603db0f440bff07f77c740989515c544a39"},
+ {"arm", "v6", "sha256:f365626a556e58189fc21d099fc64603db0f440bff07f77c740989515c544a39"},
+ {"arm", "v5", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53"},
+ {"arm", "", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53"},
+ {"arm", "unrecognized-present", "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990"},
+ {"arm", "unrecognized-not-present", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53"},
+ },
+ unmatchedInstances: []string{
+ "unmatched",
+ },
+ },
+ {
+ listFile: "oci1index.json",
+ matchedInstances: []expectedMatch{
+ {"amd64", "", "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270"},
+ {"ppc64le", "", "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"},
+ },
+ unmatchedInstances: []string{
+ "unmatched",
+ },
+ },
+ } {
+ rawManifest, err := os.ReadFile(filepath.Join("testdata", manifestList.listFile))
+ require.NoError(t, err)
+ for _, chooseInstance := range chooseInstanceCalls {
+ for _, match := range manifestList.matchedInstances {
+ testName := fmt.Sprintf("%s %q+%q", manifestList.listFile, match.arch, match.variant)
+ digest, err := chooseInstance(&types.SystemContext{
+ ArchitectureChoice: match.arch,
+ VariantChoice: match.variant,
+ OSChoice: "linux",
+ }, rawManifest)
+ require.NoError(t, err, testName)
+ assert.Equal(t, match.instanceDigest, digest, testName)
+ }
+ for _, arch := range manifestList.unmatchedInstances {
+ _, err := chooseInstance(&types.SystemContext{
+ ArchitectureChoice: arch,
+ OSChoice: "linux",
+ }, rawManifest)
+ assert.Error(t, err)
+ }
+ }
+ }
+}
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
new file mode 100644
index 0000000..1dbcc14
--- /dev/null
+++ b/internal/manifest/manifest.go
@@ -0,0 +1,167 @@
+package manifest
+
+import (
+ "encoding/json"
+
+ "github.com/containers/libtrust"
+ digest "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// FIXME: Should we just use docker/distribution and docker/docker implementations directly?
+
+// FIXME(runcom, mitr): should we have a mediatype pkg??
+const (
+ // DockerV2Schema1MediaType MIME type represents Docker manifest schema 1
+ DockerV2Schema1MediaType = "application/vnd.docker.distribution.manifest.v1+json"
+ // DockerV2Schema1MediaType MIME type represents Docker manifest schema 1 with a JWS signature
+ DockerV2Schema1SignedMediaType = "application/vnd.docker.distribution.manifest.v1+prettyjws"
+ // DockerV2Schema2MediaType MIME type represents Docker manifest schema 2
+ DockerV2Schema2MediaType = "application/vnd.docker.distribution.manifest.v2+json"
+ // DockerV2Schema2ConfigMediaType is the MIME type used for schema 2 config blobs.
+ DockerV2Schema2ConfigMediaType = "application/vnd.docker.container.image.v1+json"
+ // DockerV2Schema2LayerMediaType is the MIME type used for schema 2 layers.
+ DockerV2Schema2LayerMediaType = "application/vnd.docker.image.rootfs.diff.tar.gzip"
+ // DockerV2SchemaLayerMediaTypeUncompressed is the mediaType used for uncompressed layers.
+ DockerV2SchemaLayerMediaTypeUncompressed = "application/vnd.docker.image.rootfs.diff.tar"
+ // DockerV2ListMediaType MIME type represents Docker manifest schema 2 list
+ DockerV2ListMediaType = "application/vnd.docker.distribution.manifest.list.v2+json"
+ // DockerV2Schema2ForeignLayerMediaType is the MIME type used for schema 2 foreign layers.
+ DockerV2Schema2ForeignLayerMediaType = "application/vnd.docker.image.rootfs.foreign.diff.tar"
+ // DockerV2Schema2ForeignLayerMediaType is the MIME type used for gzipped schema 2 foreign layers.
+ DockerV2Schema2ForeignLayerMediaTypeGzip = "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip"
+)
+
+// GuessMIMEType guesses MIME type of a manifest and returns it _if it is recognized_, or "" if unknown or unrecognized.
+// FIXME? We should, in general, prefer out-of-band MIME type instead of blindly parsing the manifest,
+// but we may not have such metadata available (e.g. when the manifest is a local file).
+// This is publicly visible as c/image/manifest.GuessMIMEType.
+func GuessMIMEType(manifest []byte) string {
+ // A subset of manifest fields; the rest is silently ignored by json.Unmarshal.
+ // Also docker/distribution/manifest.Versioned.
+ meta := struct {
+ MediaType string `json:"mediaType"`
+ SchemaVersion int `json:"schemaVersion"`
+ Signatures any `json:"signatures"`
+ }{}
+ if err := json.Unmarshal(manifest, &meta); err != nil {
+ return ""
+ }
+
+ switch meta.MediaType {
+ case DockerV2Schema2MediaType, DockerV2ListMediaType,
+ imgspecv1.MediaTypeImageManifest, imgspecv1.MediaTypeImageIndex: // A recognized type.
+ return meta.MediaType
+ }
+ // this is the only way the function can return DockerV2Schema1MediaType, and recognizing that is essential for stripping the JWS signatures = computing the correct manifest digest.
+ switch meta.SchemaVersion {
+ case 1:
+ if meta.Signatures != nil {
+ return DockerV2Schema1SignedMediaType
+ }
+ return DockerV2Schema1MediaType
+ case 2:
+ // Best effort to understand if this is an OCI image since mediaType
+ // wasn't in the manifest for OCI image-spec < 1.0.2.
+ // For docker v2s2 meta.MediaType should have been set. But given the data, this is our best guess.
+ ociMan := struct {
+ Config struct {
+ MediaType string `json:"mediaType"`
+ } `json:"config"`
+ }{}
+ if err := json.Unmarshal(manifest, &ociMan); err != nil {
+ return ""
+ }
+ switch ociMan.Config.MediaType {
+ case imgspecv1.MediaTypeImageConfig:
+ return imgspecv1.MediaTypeImageManifest
+ case DockerV2Schema2ConfigMediaType:
+ // This case should not happen since a Docker image
+ // must declare a top-level media type and
+ // `meta.MediaType` has already been checked.
+ return DockerV2Schema2MediaType
+ }
+ // Maybe an image index or an OCI artifact.
+ ociIndex := struct {
+ Manifests []imgspecv1.Descriptor `json:"manifests"`
+ }{}
+ if err := json.Unmarshal(manifest, &ociIndex); err != nil {
+ return ""
+ }
+ if len(ociIndex.Manifests) != 0 {
+ if ociMan.Config.MediaType == "" {
+ return imgspecv1.MediaTypeImageIndex
+ }
+ // FIXME: this is mixing media types of manifests and configs.
+ return ociMan.Config.MediaType
+ }
+ // It's most likely an OCI artifact with a custom config media
+ // type which is not (and cannot) be covered by the media-type
+ // checks cabove.
+ return imgspecv1.MediaTypeImageManifest
+ }
+ return ""
+}
+
+// Digest returns the a digest of a docker manifest, with any necessary implied transformations like stripping v1s1 signatures.
+// This is publicly visible as c/image/manifest.Digest.
+func Digest(manifest []byte) (digest.Digest, error) {
+ if GuessMIMEType(manifest) == DockerV2Schema1SignedMediaType {
+ sig, err := libtrust.ParsePrettySignature(manifest, "signatures")
+ if err != nil {
+ return "", err
+ }
+ manifest, err = sig.Payload()
+ if err != nil {
+ // Coverage: This should never happen, libtrust's Payload() can fail only if joseBase64UrlDecode() fails, on a string
+ // that libtrust itself has josebase64UrlEncode()d
+ return "", err
+ }
+ }
+
+ return digest.FromBytes(manifest), nil
+}
+
+// MatchesDigest returns true iff the manifest matches expectedDigest.
+// Error may be set if this returns false.
+// Note that this is not doing ConstantTimeCompare; by the time we get here, the cryptographic signature must already have been verified,
+// or we are not using a cryptographic channel and the attacker can modify the digest along with the manifest blob.
+// This is publicly visible as c/image/manifest.MatchesDigest.
+func MatchesDigest(manifest []byte, expectedDigest digest.Digest) (bool, error) {
+ // This should eventually support various digest types.
+ actualDigest, err := Digest(manifest)
+ if err != nil {
+ return false, err
+ }
+ return expectedDigest == actualDigest, nil
+}
+
+// NormalizedMIMEType returns the effective MIME type of a manifest MIME type returned by a server,
+// centralizing various workarounds.
+// This is publicly visible as c/image/manifest.NormalizedMIMEType.
+func NormalizedMIMEType(input string) string {
+ switch input {
+ // "application/json" is a valid v2s1 value per https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-1.md .
+ // This works for now, when nothing else seems to return "application/json"; if that were not true, the mapping/detection might
+ // need to happen within the ImageSource.
+ case "application/json":
+ return DockerV2Schema1SignedMediaType
+ case DockerV2Schema1MediaType, DockerV2Schema1SignedMediaType,
+ imgspecv1.MediaTypeImageManifest,
+ imgspecv1.MediaTypeImageIndex,
+ DockerV2Schema2MediaType,
+ DockerV2ListMediaType:
+ return input
+ default:
+ // If it's not a recognized manifest media type, or we have failed determining the type, we'll try one last time
+ // to deserialize using v2s1 as per https://github.com/docker/distribution/blob/master/manifests.go#L108
+ // and https://github.com/docker/distribution/blob/master/manifest/schema1/manifest.go#L50
+ //
+ // Crane registries can also return "text/plain", or pretty much anything else depending on a file extension “recognized” in the tag.
+ // This makes no real sense, but it happens
+ // because requests for manifests are
+ // redirected to a content distribution
+ // network which is configured that way. See https://bugzilla.redhat.com/show_bug.cgi?id=1389442
+ return DockerV2Schema1SignedMediaType
+ }
+}
diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go
new file mode 100644
index 0000000..8dc9879
--- /dev/null
+++ b/internal/manifest/manifest_test.go
@@ -0,0 +1,134 @@
+package manifest
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ digest "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ digestSha256EmptyTar = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+)
+
+func TestGuessMIMEType(t *testing.T) {
+ cases := []struct {
+ path string
+ mimeType string
+ }{
+ {"v2s2.manifest.json", DockerV2Schema2MediaType},
+ {"v2list.manifest.json", DockerV2ListMediaType},
+ {"v2s1.manifest.json", DockerV2Schema1SignedMediaType},
+ {"v2s1-unsigned.manifest.json", DockerV2Schema1MediaType},
+ {"v2s1-invalid-signatures.manifest.json", DockerV2Schema1SignedMediaType},
+ {"v2s2nomime.manifest.json", DockerV2Schema2MediaType}, // It is unclear whether this one is legal, but we should guess v2s2 if anything at all.
+ {"unknown-version.manifest.json", ""},
+ {"non-json.manifest.json", ""}, // Not a manifest (nor JSON) at all
+ {"ociv1.manifest.json", imgspecv1.MediaTypeImageManifest},
+ {"ociv1.artifact.json", imgspecv1.MediaTypeImageManifest},
+ {"ociv1.image.index.json", imgspecv1.MediaTypeImageIndex},
+ {"ociv1nomime.manifest.json", imgspecv1.MediaTypeImageManifest},
+ {"ociv1nomime.artifact.json", imgspecv1.MediaTypeImageManifest},
+ {"ociv1nomime.image.index.json", imgspecv1.MediaTypeImageIndex},
+ }
+
+ for _, c := range cases {
+ manifest, err := os.ReadFile(filepath.Join("testdata", c.path))
+ require.NoError(t, err)
+ mimeType := GuessMIMEType(manifest)
+ assert.Equal(t, c.mimeType, mimeType, c.path)
+ }
+}
+
+func TestDigest(t *testing.T) {
+ cases := []struct {
+ path string
+ expectedDigest digest.Digest
+ }{
+ {"v2s2.manifest.json", TestDockerV2S2ManifestDigest},
+ {"v2s1.manifest.json", TestDockerV2S1ManifestDigest},
+ {"v2s1-unsigned.manifest.json", TestDockerV2S1UnsignedManifestDigest},
+ }
+ for _, c := range cases {
+ manifest, err := os.ReadFile(filepath.Join("testdata", c.path))
+ require.NoError(t, err)
+ actualDigest, err := Digest(manifest)
+ require.NoError(t, err)
+ assert.Equal(t, c.expectedDigest, actualDigest)
+ }
+
+ manifest, err := os.ReadFile("testdata/v2s1-invalid-signatures.manifest.json")
+ require.NoError(t, err)
+ _, err = Digest(manifest)
+ assert.Error(t, err)
+
+ actualDigest, err := Digest([]byte{})
+ require.NoError(t, err)
+ assert.Equal(t, digest.Digest(digestSha256EmptyTar), actualDigest)
+}
+
+func TestMatchesDigest(t *testing.T) {
+ cases := []struct {
+ path string
+ expectedDigest digest.Digest
+ result bool
+ }{
+ // Success
+ {"v2s2.manifest.json", TestDockerV2S2ManifestDigest, true},
+ {"v2s1.manifest.json", TestDockerV2S1ManifestDigest, true},
+ // No match (switched s1/s2)
+ {"v2s2.manifest.json", TestDockerV2S1ManifestDigest, false},
+ {"v2s1.manifest.json", TestDockerV2S2ManifestDigest, false},
+ // Unrecognized algorithm
+ {"v2s2.manifest.json", digest.Digest("md5:2872f31c5c1f62a694fbd20c1e85257c"), false},
+ // Mangled format
+ {"v2s2.manifest.json", digest.Digest(TestDockerV2S2ManifestDigest.String() + "abc"), false},
+ {"v2s2.manifest.json", digest.Digest(TestDockerV2S2ManifestDigest.String()[:20]), false},
+ {"v2s2.manifest.json", digest.Digest(""), false},
+ }
+ for _, c := range cases {
+ manifest, err := os.ReadFile(filepath.Join("testdata", c.path))
+ require.NoError(t, err)
+ res, err := MatchesDigest(manifest, c.expectedDigest)
+ require.NoError(t, err)
+ assert.Equal(t, c.result, res)
+ }
+
+ manifest, err := os.ReadFile("testdata/v2s1-invalid-signatures.manifest.json")
+ require.NoError(t, err)
+ // Even a correct SHA256 hash is rejected if we can't strip the JSON signature.
+ res, err := MatchesDigest(manifest, digest.FromBytes(manifest))
+ assert.False(t, res)
+ assert.Error(t, err)
+
+ res, err = MatchesDigest([]byte{}, digest.Digest(digestSha256EmptyTar))
+ assert.True(t, res)
+ assert.NoError(t, err)
+}
+
+func TestNormalizedMIMEType(t *testing.T) {
+ for _, c := range []string{ // Valid MIME types, normalized to themselves
+ DockerV2Schema1MediaType,
+ DockerV2Schema1SignedMediaType,
+ DockerV2Schema2MediaType,
+ DockerV2ListMediaType,
+ imgspecv1.MediaTypeImageManifest,
+ imgspecv1.MediaTypeImageIndex,
+ } {
+ res := NormalizedMIMEType(c)
+ assert.Equal(t, c, res, c)
+ }
+ for _, c := range []string{
+ "application/json",
+ "text/plain",
+ "not at all a valid MIME type",
+ "",
+ } {
+ res := NormalizedMIMEType(c)
+ assert.Equal(t, DockerV2Schema1SignedMediaType, res, c)
+ }
+}
diff --git a/internal/manifest/oci_index.go b/internal/manifest/oci_index.go
new file mode 100644
index 0000000..d8d0651
--- /dev/null
+++ b/internal/manifest/oci_index.go
@@ -0,0 +1,446 @@
+package manifest
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "runtime"
+
+ platform "github.com/containers/image/v5/internal/pkg/platform"
+ compression "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspec "github.com/opencontainers/image-spec/specs-go"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "golang.org/x/exp/maps"
+ "golang.org/x/exp/slices"
+)
+
+const (
+ // OCI1InstanceAnnotationCompressionZSTD is an annotation name that can be placed on a manifest descriptor in an OCI index.
+ // The value of the annotation must be the string "true".
+ // If this annotation is present on a manifest, consuming that image instance requires support for Zstd compression.
+ // That also suggests that this instance benefits from
+ // Zstd compression, so it can be preferred by compatible consumers over instances that
+ // use gzip, depending on their local policy.
+ OCI1InstanceAnnotationCompressionZSTD = "io.github.containers.compression.zstd"
+ OCI1InstanceAnnotationCompressionZSTDValue = "true"
+)
+
+// OCI1IndexPublic is just an alias for the OCI index type, but one which we can
+// provide methods for.
+// This is publicly visible as c/image/manifest.OCI1Index
+// Internal users should usually use OCI1Index instead.
+type OCI1IndexPublic struct {
+ imgspecv1.Index
+}
+
+// MIMEType returns the MIME type of this particular manifest index.
+func (index *OCI1IndexPublic) MIMEType() string {
+ return imgspecv1.MediaTypeImageIndex
+}
+
+// Instances returns a slice of digests of the manifests that this index knows of.
+func (index *OCI1IndexPublic) Instances() []digest.Digest {
+ results := make([]digest.Digest, len(index.Manifests))
+ for i, m := range index.Manifests {
+ results[i] = m.Digest
+ }
+ return results
+}
+
+// Instance returns the ListUpdate of a particular instance in the index.
+func (index *OCI1IndexPublic) Instance(instanceDigest digest.Digest) (ListUpdate, error) {
+ for _, manifest := range index.Manifests {
+ if manifest.Digest == instanceDigest {
+ ret := ListUpdate{
+ Digest: manifest.Digest,
+ Size: manifest.Size,
+ MediaType: manifest.MediaType,
+ }
+ ret.ReadOnly.Platform = manifest.Platform
+ ret.ReadOnly.Annotations = manifest.Annotations
+ ret.ReadOnly.CompressionAlgorithmNames = annotationsToCompressionAlgorithmNames(manifest.Annotations)
+ return ret, nil
+ }
+ }
+ return ListUpdate{}, fmt.Errorf("unable to find instance %s in OCI1Index", instanceDigest)
+}
+
+// UpdateInstances updates the sizes, digests, and media types of the manifests
+// which the list catalogs.
+func (index *OCI1IndexPublic) UpdateInstances(updates []ListUpdate) error {
+ editInstances := []ListEdit{}
+ for i, instance := range updates {
+ editInstances = append(editInstances, ListEdit{
+ UpdateOldDigest: index.Manifests[i].Digest,
+ UpdateDigest: instance.Digest,
+ UpdateSize: instance.Size,
+ UpdateMediaType: instance.MediaType,
+ ListOperation: ListOpUpdate})
+ }
+ return index.editInstances(editInstances)
+}
+
+func annotationsToCompressionAlgorithmNames(annotations map[string]string) []string {
+ result := make([]string, 0, 1)
+ if annotations[OCI1InstanceAnnotationCompressionZSTD] == OCI1InstanceAnnotationCompressionZSTDValue {
+ result = append(result, compression.ZstdAlgorithmName)
+ }
+ // No compression was detected, hence assume instance has default compression `Gzip`
+ if len(result) == 0 {
+ result = append(result, compression.GzipAlgorithmName)
+ }
+ return result
+}
+
+func addCompressionAnnotations(compressionAlgorithms []compression.Algorithm, annotationsMap *map[string]string) {
+ // TODO: This should also delete the algorithm if map already contains an algorithm and compressionAlgorithm
+ // list has a different algorithm. To do that, we would need to modify the callers to always provide a reliable
+ // and full compressionAlghorithms list.
+ if *annotationsMap == nil && len(compressionAlgorithms) > 0 {
+ *annotationsMap = map[string]string{}
+ }
+ for _, algo := range compressionAlgorithms {
+ switch algo.Name() {
+ case compression.ZstdAlgorithmName:
+ (*annotationsMap)[OCI1InstanceAnnotationCompressionZSTD] = OCI1InstanceAnnotationCompressionZSTDValue
+ default:
+ continue
+ }
+ }
+}
+
+func (index *OCI1IndexPublic) editInstances(editInstances []ListEdit) error {
+ addedEntries := []imgspecv1.Descriptor{}
+ updatedAnnotations := false
+ for i, editInstance := range editInstances {
+ switch editInstance.ListOperation {
+ case ListOpUpdate:
+ if err := editInstance.UpdateOldDigest.Validate(); err != nil {
+ return fmt.Errorf("OCI1Index.EditInstances: Attempting to update %s which is an invalid digest: %w", editInstance.UpdateOldDigest, err)
+ }
+ if err := editInstance.UpdateDigest.Validate(); err != nil {
+ return fmt.Errorf("OCI1Index.EditInstances: Modified digest %s is an invalid digest: %w", editInstance.UpdateDigest, err)
+ }
+ targetIndex := slices.IndexFunc(index.Manifests, func(m imgspecv1.Descriptor) bool {
+ return m.Digest == editInstance.UpdateOldDigest
+ })
+ if targetIndex == -1 {
+ return fmt.Errorf("OCI1Index.EditInstances: digest %s not found", editInstance.UpdateOldDigest)
+ }
+ index.Manifests[targetIndex].Digest = editInstance.UpdateDigest
+ if editInstance.UpdateSize < 0 {
+ return fmt.Errorf("update %d of %d passed to OCI1Index.UpdateInstances had an invalid size (%d)", i+1, len(editInstances), editInstance.UpdateSize)
+ }
+ index.Manifests[targetIndex].Size = editInstance.UpdateSize
+ if editInstance.UpdateMediaType == "" {
+ return fmt.Errorf("update %d of %d passed to OCI1Index.UpdateInstances had no media type (was %q)", i+1, len(editInstances), index.Manifests[i].MediaType)
+ }
+ index.Manifests[targetIndex].MediaType = editInstance.UpdateMediaType
+ if editInstance.UpdateAnnotations != nil {
+ updatedAnnotations = true
+ if editInstance.UpdateAffectAnnotations {
+ index.Manifests[targetIndex].Annotations = maps.Clone(editInstance.UpdateAnnotations)
+ } else {
+ if index.Manifests[targetIndex].Annotations == nil {
+ index.Manifests[targetIndex].Annotations = map[string]string{}
+ }
+ maps.Copy(index.Manifests[targetIndex].Annotations, editInstance.UpdateAnnotations)
+ }
+ }
+ addCompressionAnnotations(editInstance.UpdateCompressionAlgorithms, &index.Manifests[targetIndex].Annotations)
+ case ListOpAdd:
+ annotations := map[string]string{}
+ if editInstance.AddAnnotations != nil {
+ annotations = maps.Clone(editInstance.AddAnnotations)
+ }
+ addCompressionAnnotations(editInstance.AddCompressionAlgorithms, &annotations)
+ addedEntries = append(addedEntries, imgspecv1.Descriptor{
+ MediaType: editInstance.AddMediaType,
+ Size: editInstance.AddSize,
+ Digest: editInstance.AddDigest,
+ Platform: editInstance.AddPlatform,
+ Annotations: annotations})
+ default:
+ return fmt.Errorf("internal error: invalid operation: %d", editInstance.ListOperation)
+ }
+ }
+ if len(addedEntries) != 0 {
+ // slices.Clone() here to ensure the slice uses a private backing array;
+ // an external caller could have manually created OCI1IndexPublic with a slice with extra capacity.
+ index.Manifests = append(slices.Clone(index.Manifests), addedEntries...)
+ }
+ if len(addedEntries) != 0 || updatedAnnotations {
+ slices.SortStableFunc(index.Manifests, func(a, b imgspecv1.Descriptor) int {
+ // FIXME? With Go 1.21 and cmp.Compare available, turn instanceIsZstd into an integer score that can be compared, and generalizes
+ // into more algorithms?
+ aZstd := instanceIsZstd(a)
+ bZstd := instanceIsZstd(b)
+ switch {
+ case aZstd == bZstd:
+ return 0
+ case !aZstd: // Implies bZstd
+ return -1
+ default: // aZstd && !bZstd
+ return 1
+ }
+ })
+ }
+ return nil
+}
+
+func (index *OCI1Index) EditInstances(editInstances []ListEdit) error {
+ return index.editInstances(editInstances)
+}
+
+// instanceIsZstd returns true if instance is a zstd instance otherwise false.
+func instanceIsZstd(manifest imgspecv1.Descriptor) bool {
+ if value, ok := manifest.Annotations[OCI1InstanceAnnotationCompressionZSTD]; ok && value == "true" {
+ return true
+ }
+ return false
+}
+
+type instanceCandidate struct {
+ platformIndex int // Index of the candidate in platform.WantedPlatforms: lower numbers are preferred; or math.maxInt if the candidate doesn’t have a platform
+ isZstd bool // tells if particular instance if zstd instance
+ manifestPosition int // A zero-based index of the instance in the manifest list
+ digest digest.Digest // Instance digest
+}
+
+func (ic instanceCandidate) isPreferredOver(other *instanceCandidate, preferGzip bool) bool {
+ switch {
+ case ic.platformIndex != other.platformIndex:
+ return ic.platformIndex < other.platformIndex
+ case ic.isZstd != other.isZstd:
+ if !preferGzip {
+ return ic.isZstd
+ } else {
+ return !ic.isZstd
+ }
+ case ic.manifestPosition != other.manifestPosition:
+ return ic.manifestPosition < other.manifestPosition
+ }
+ panic("internal error: invalid comparison between two candidates") // This should not be reachable because in all calls we make, the two candidates differ at least in manifestPosition.
+}
+
+// chooseInstance is a private equivalent to ChooseInstanceByCompression,
+// shared by ChooseInstance and ChooseInstanceByCompression.
+func (index *OCI1IndexPublic) chooseInstance(ctx *types.SystemContext, preferGzip types.OptionalBool) (digest.Digest, error) {
+ didPreferGzip := false
+ if preferGzip == types.OptionalBoolTrue {
+ didPreferGzip = true
+ }
+ wantedPlatforms, err := platform.WantedPlatforms(ctx)
+ if err != nil {
+ return "", fmt.Errorf("getting platform information %#v: %w", ctx, err)
+ }
+ var bestMatch *instanceCandidate
+ bestMatch = nil
+ for manifestIndex, d := range index.Manifests {
+ candidate := instanceCandidate{platformIndex: math.MaxInt, manifestPosition: manifestIndex, isZstd: instanceIsZstd(d), digest: d.Digest}
+ if d.Platform != nil {
+ imagePlatform := ociPlatformClone(*d.Platform)
+ platformIndex := slices.IndexFunc(wantedPlatforms, func(wantedPlatform imgspecv1.Platform) bool {
+ return platform.MatchesPlatform(imagePlatform, wantedPlatform)
+ })
+ if platformIndex == -1 {
+ continue
+ }
+ candidate.platformIndex = platformIndex
+ }
+ if bestMatch == nil || candidate.isPreferredOver(bestMatch, didPreferGzip) {
+ bestMatch = &candidate
+ }
+ }
+ if bestMatch != nil {
+ return bestMatch.digest, nil
+ }
+ return "", fmt.Errorf("no image found in image index for architecture %s, variant %q, OS %s", wantedPlatforms[0].Architecture, wantedPlatforms[0].Variant, wantedPlatforms[0].OS)
+}
+
+func (index *OCI1Index) ChooseInstanceByCompression(ctx *types.SystemContext, preferGzip types.OptionalBool) (digest.Digest, error) {
+ return index.chooseInstance(ctx, preferGzip)
+}
+
+// ChooseInstance parses blob as an oci v1 manifest index, and returns the digest
+// of the image which is appropriate for the current environment.
+func (index *OCI1IndexPublic) ChooseInstance(ctx *types.SystemContext) (digest.Digest, error) {
+ return index.chooseInstance(ctx, types.OptionalBoolFalse)
+}
+
+// Serialize returns the index in a blob format.
+// NOTE: Serialize() does not in general reproduce the original blob if this object was loaded from one, even if no modifications were made!
+func (index *OCI1IndexPublic) Serialize() ([]byte, error) {
+ buf, err := json.Marshal(index)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling OCI1Index %#v: %w", index, err)
+ }
+ return buf, nil
+}
+
+// OCI1IndexPublicFromComponents creates an OCI1 image index instance from the
+// supplied data.
+// This is publicly visible as c/image/manifest.OCI1IndexFromComponents.
+func OCI1IndexPublicFromComponents(components []imgspecv1.Descriptor, annotations map[string]string) *OCI1IndexPublic {
+ index := OCI1IndexPublic{
+ imgspecv1.Index{
+ Versioned: imgspec.Versioned{SchemaVersion: 2},
+ MediaType: imgspecv1.MediaTypeImageIndex,
+ Manifests: make([]imgspecv1.Descriptor, len(components)),
+ Annotations: maps.Clone(annotations),
+ },
+ }
+ for i, component := range components {
+ var platform *imgspecv1.Platform
+ if component.Platform != nil {
+ platformCopy := ociPlatformClone(*component.Platform)
+ platform = &platformCopy
+ }
+ m := imgspecv1.Descriptor{
+ MediaType: component.MediaType,
+ Size: component.Size,
+ Digest: component.Digest,
+ URLs: slices.Clone(component.URLs),
+ Annotations: maps.Clone(component.Annotations),
+ Platform: platform,
+ }
+ index.Manifests[i] = m
+ }
+ return &index
+}
+
+// OCI1IndexPublicClone creates a deep copy of the passed-in index.
+// This is publicly visible as c/image/manifest.OCI1IndexClone.
+func OCI1IndexPublicClone(index *OCI1IndexPublic) *OCI1IndexPublic {
+ return OCI1IndexPublicFromComponents(index.Manifests, index.Annotations)
+}
+
+// ToOCI1Index returns the index encoded as an OCI1 index.
+func (index *OCI1IndexPublic) ToOCI1Index() (*OCI1IndexPublic, error) {
+ return OCI1IndexPublicClone(index), nil
+}
+
+// ToSchema2List returns the index encoded as a Schema2 list.
+func (index *OCI1IndexPublic) ToSchema2List() (*Schema2ListPublic, error) {
+ components := make([]Schema2ManifestDescriptor, 0, len(index.Manifests))
+ for _, manifest := range index.Manifests {
+ platform := manifest.Platform
+ if platform == nil {
+ platform = &imgspecv1.Platform{
+ OS: runtime.GOOS,
+ Architecture: runtime.GOARCH,
+ }
+ }
+ components = append(components, Schema2ManifestDescriptor{
+ Schema2Descriptor{
+ MediaType: manifest.MediaType,
+ Size: manifest.Size,
+ Digest: manifest.Digest,
+ URLs: slices.Clone(manifest.URLs),
+ },
+ schema2PlatformSpecFromOCIPlatform(*platform),
+ })
+ }
+ s2 := Schema2ListPublicFromComponents(components)
+ return s2, nil
+}
+
+// OCI1IndexPublicFromManifest creates an OCI1 manifest index instance from marshalled
+// JSON, presumably generated by encoding a OCI1 manifest index.
+// This is publicly visible as c/image/manifest.OCI1IndexFromManifest.
+func OCI1IndexPublicFromManifest(manifest []byte) (*OCI1IndexPublic, error) {
+ index := OCI1IndexPublic{
+ Index: imgspecv1.Index{
+ Versioned: imgspec.Versioned{SchemaVersion: 2},
+ MediaType: imgspecv1.MediaTypeImageIndex,
+ Manifests: []imgspecv1.Descriptor{},
+ Annotations: make(map[string]string),
+ },
+ }
+ if err := json.Unmarshal(manifest, &index); err != nil {
+ return nil, fmt.Errorf("unmarshaling OCI1Index %q: %w", string(manifest), err)
+ }
+ if err := ValidateUnambiguousManifestFormat(manifest, imgspecv1.MediaTypeImageIndex,
+ AllowedFieldManifests); err != nil {
+ return nil, err
+ }
+ return &index, nil
+}
+
+// Clone returns a deep copy of this list and its contents.
+func (index *OCI1IndexPublic) Clone() ListPublic {
+ return OCI1IndexPublicClone(index)
+}
+
+// ConvertToMIMEType converts the passed-in image index to a manifest list of
+// the specified type.
+func (index *OCI1IndexPublic) ConvertToMIMEType(manifestMIMEType string) (ListPublic, error) {
+ switch normalized := NormalizedMIMEType(manifestMIMEType); normalized {
+ case DockerV2ListMediaType:
+ return index.ToSchema2List()
+ case imgspecv1.MediaTypeImageIndex:
+ return index.Clone(), nil
+ case DockerV2Schema1MediaType, DockerV2Schema1SignedMediaType, imgspecv1.MediaTypeImageManifest, DockerV2Schema2MediaType:
+ return nil, fmt.Errorf("Can not convert image index to MIME type %q, which is not a list type", manifestMIMEType)
+ default:
+ // Note that this may not be reachable, NormalizedMIMEType has a default for unknown values.
+ return nil, fmt.Errorf("Unimplemented manifest MIME type %s", manifestMIMEType)
+ }
+}
+
+type OCI1Index struct {
+ OCI1IndexPublic
+}
+
+func oci1IndexFromPublic(public *OCI1IndexPublic) *OCI1Index {
+ return &OCI1Index{*public}
+}
+
+func (index *OCI1Index) CloneInternal() List {
+ return oci1IndexFromPublic(OCI1IndexPublicClone(&index.OCI1IndexPublic))
+}
+
+func (index *OCI1Index) Clone() ListPublic {
+ return index.CloneInternal()
+}
+
+// OCI1IndexFromManifest creates a OCI1 manifest list instance from marshalled
+// JSON, presumably generated by encoding a OCI1 manifest list.
+func OCI1IndexFromManifest(manifest []byte) (*OCI1Index, error) {
+ public, err := OCI1IndexPublicFromManifest(manifest)
+ if err != nil {
+ return nil, err
+ }
+ return oci1IndexFromPublic(public), nil
+}
+
+// ociPlatformClone returns an independent copy of p.
+func ociPlatformClone(p imgspecv1.Platform) imgspecv1.Platform {
+ // The only practical way in Go to give read-only access to an array is to copy it.
+ // The only practical way in Go to copy a deep structure is to either do it manually field by field,
+ // or to use reflection (incl. a round-trip through JSON, which uses reflection).
+ //
+ // The combination of the two is just sad, and leads to code like this, which will
+ // need to be updated with every new Platform field.
+ return imgspecv1.Platform{
+ Architecture: p.Architecture,
+ OS: p.OS,
+ OSVersion: p.OSVersion,
+ OSFeatures: slices.Clone(p.OSFeatures),
+ Variant: p.Variant,
+ }
+}
+
+// schema2PlatformSpecFromOCIPlatform converts an OCI platform p to the schema2 structure.
+func schema2PlatformSpecFromOCIPlatform(p imgspecv1.Platform) Schema2PlatformSpec {
+ return Schema2PlatformSpec{
+ Architecture: p.Architecture,
+ OS: p.OS,
+ OSVersion: p.OSVersion,
+ OSFeatures: slices.Clone(p.OSFeatures),
+ Variant: p.Variant,
+ Features: nil,
+ }
+}
diff --git a/internal/manifest/oci_index_test.go b/internal/manifest/oci_index_test.go
new file mode 100644
index 0000000..3c9a3ac
--- /dev/null
+++ b/internal/manifest/oci_index_test.go
@@ -0,0 +1,265 @@
+package manifest
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/containers/image/v5/pkg/compression"
+ compressionTypes "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOCI1IndexPublicFromManifest(t *testing.T) {
+ validManifest, err := os.ReadFile(filepath.Join("testdata", "ociv1.image.index.json"))
+ require.NoError(t, err)
+
+ parser := func(m []byte) error {
+ _, err := OCI1IndexPublicFromManifest(m)
+ return err
+ }
+ // Schema mismatch is rejected
+ testManifestFixturesAreRejected(t, parser, []string{
+ "schema2-to-schema1-by-docker.json",
+ "v2s2.manifest.json",
+ // Not "v2list.manifest.json" yet, without mediaType the two are too similar to tell the difference.
+ "ociv1.manifest.json",
+ })
+ // Extra fields are rejected
+ testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
+}
+
+func TestOCI1IndexFromManifest(t *testing.T) {
+ validManifest, err := os.ReadFile(filepath.Join("testdata", "ociv1.image.index.json"))
+ require.NoError(t, err)
+
+ parser := func(m []byte) error {
+ _, err := OCI1IndexFromManifest(m)
+ return err
+ }
+ // Schema mismatch is rejected
+ testManifestFixturesAreRejected(t, parser, []string{
+ "schema2-to-schema1-by-docker.json",
+ "v2s2.manifest.json",
+ // Not "v2list.manifest.json" yet, without mediaType the two are too similar to tell the difference.
+ "ociv1.manifest.json",
+ })
+ // Extra fields are rejected
+ testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
+}
+
+func TestOCI1EditInstances(t *testing.T) {
+ validManifest, err := os.ReadFile(filepath.Join("testdata", "ociv1.image.index.json"))
+ require.NoError(t, err)
+ list, err := ListFromBlob(validManifest, GuessMIMEType(validManifest))
+ require.NoError(t, err)
+
+ expectedDigests := list.Instances()
+ editInstances := []ListEdit{}
+ editInstances = append(editInstances, ListEdit{
+ UpdateOldDigest: list.Instances()[0],
+ UpdateDigest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ UpdateSize: 32,
+ UpdateMediaType: "something",
+ ListOperation: ListOpUpdate})
+ err = list.EditInstances(editInstances)
+ require.NoError(t, err)
+
+ expectedDigests[0] = editInstances[0].UpdateDigest
+ // order of old elements must remain same.
+ assert.Equal(t, list.Instances(), expectedDigests)
+
+ instance, err := list.Instance(list.Instances()[0])
+ require.NoError(t, err)
+ assert.Equal(t, "something", instance.MediaType)
+ assert.Equal(t, int64(32), instance.Size)
+ // platform must match with what was set in `ociv1.image.index.json` for the first instance
+ assert.Equal(t, &imgspecv1.Platform{Architecture: "ppc64le", OS: "linux", OSVersion: "", OSFeatures: []string(nil), Variant: ""}, instance.ReadOnly.Platform)
+ assert.Equal(t, []string{compressionTypes.GzipAlgorithmName}, instance.ReadOnly.CompressionAlgorithmNames)
+
+ // Create a fresh list
+ list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
+ require.NoError(t, err)
+
+ // Verify correct zstd sorting
+ editInstances = []ListEdit{}
+ annotations := map[string]string{"io.github.containers.compression.zstd": "true"}
+ // without zstd
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ ListOperation: ListOpAdd})
+ // with zstd
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ AddAnnotations: annotations,
+ ListOperation: ListOpAdd})
+ // with zstd but with compression, annotation must be added automatically
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ AddCompressionAlgorithms: []compression.Algorithm{compression.Zstd},
+ AddAnnotations: map[string]string{},
+ ListOperation: ListOpAdd})
+ // with zstd but with compression, annotation must be added automatically and AddAnnotations is unset
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ AddCompressionAlgorithms: []compression.Algorithm{compression.Zstd},
+ ListOperation: ListOpAdd})
+ // without zstd
+ editInstances = append(editInstances, ListEdit{
+ AddDigest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ AddSize: 32,
+ AddMediaType: "application/vnd.oci.image.manifest.v1+json",
+ AddPlatform: &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
+ ListOperation: ListOpAdd})
+ err = list.EditInstances(editInstances)
+ require.NoError(t, err)
+
+ // Zstd should be kept on lowest priority as compared to the default gzip ones and order of prior elements must be preserved.
+ assert.Equal(t, list.Instances(), []digest.Digest{digest.Digest("sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"), digest.Digest("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270"), digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), digest.Digest("sha256:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh"), digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")})
+
+ instance, err = list.Instance(digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
+ require.NoError(t, err)
+ // Verify if annotations are preserved and correctly set in ReadOnly field.
+ assert.Equal(t, annotations, instance.ReadOnly.Annotations)
+ // Verify compression of an instance is added to the ReadOnly CompressionAlgorithmNames where compression name
+ // is internally derived from the appropriate annotations.
+ assert.Equal(t, []string{compressionTypes.ZstdAlgorithmName}, instance.ReadOnly.CompressionAlgorithmNames)
+
+ // Update list and remove zstd annotation from existing instance, and verify if resorting works
+ editInstances = []ListEdit{}
+ editInstances = append(editInstances, ListEdit{
+ UpdateOldDigest: digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
+ UpdateDigest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ UpdateSize: 32,
+ UpdateMediaType: "application/vnd.oci.image.manifest.v1+json",
+ UpdateAffectAnnotations: true,
+ UpdateAnnotations: map[string]string{},
+ ListOperation: ListOpUpdate})
+ err = list.EditInstances(editInstances)
+ require.NoError(t, err)
+ // Digest `ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff` should be re-ordered on update.
+ assert.Equal(t, list.Instances(), []digest.Digest{digest.Digest("sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"), digest.Digest("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270"), digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), digest.Digest("sha256:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh")})
+
+}
+
+func TestOCI1IndexChooseInstanceByCompression(t *testing.T) {
+ type expectedMatch struct {
+ arch, variant string
+ instanceDigest digest.Digest
+ preferGzip bool
+ }
+ for _, manifestList := range []struct {
+ listFile string
+ matchedInstances []expectedMatch
+ unmatchedInstances []string
+ }{
+ {
+ listFile: "oci1.index.zstd-selection.json",
+ matchedInstances: []expectedMatch{
+ // out of gzip and zstd in amd64 select the first zstd image
+ {"amd64", "", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false},
+ // out of multiple gzip in arm64 select the first one to ensure original logic is prevented
+ {"arm64", "", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", false},
+ // select a signle gzip s390x image
+ {"s390x", "", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", false},
+ // out of gzip and zstd in amd64 select the first gzip image
+ {"amd64", "", "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
+ // out of multiple gzip in arm64 select the first one to ensure original logic is prevented
+ {"arm64", "", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", true},
+ // select a signle gzip s390x image
+ {"s390x", "", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", true},
+ },
+ unmatchedInstances: []string{
+ "unmatched",
+ },
+ },
+ { // Focus on ARM variant field testing
+ listFile: "ocilist-variants.json",
+ matchedInstances: []expectedMatch{
+ {"amd64", "", "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610", false},
+ {"arm", "v7", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", false},
+ {"arm", "v6", "sha256:f365626a556e58189fc21d099fc64603db0f440bff07f77c740989515c544a39", false},
+ {"arm", "v5", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", false},
+ {"arm", "", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", false},
+ {"arm", "unrecognized-present", "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990", false},
+ {"arm", "unrecognized-not-present", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", false},
+ // preferGzip true
+ {"amd64", "", "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610", true},
+ {"arm", "v7", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", true},
+ {"arm", "v6", "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", true},
+ {"arm", "v5", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", true},
+ {"arm", "", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", true},
+ {"arm", "unrecognized-present", "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990", true},
+ {"arm", "unrecognized-not-present", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", true},
+ },
+ unmatchedInstances: []string{
+ "unmatched",
+ },
+ },
+ {
+ listFile: "oci1.index.zstd-selection2.json",
+ // out of list where first instance is gzip , select the first occurrence of zstd out of many
+ matchedInstances: []expectedMatch{
+ {"amd64", "", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", false},
+ {"amd64", "", "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
+ // must return first gzip even if the first entry is zstd
+ {"arm64", "", "sha256:6dc14a60d2ba724646cfbf5fccbb9a618a5978a64a352e060b17caf5e005da9d", true},
+ // must return first zstd even if the first entry for same platform is gzip
+ {"arm64", "", "sha256:1c98002b30a71b08ab175915ce7c8fb8da9e9b502ae082d6f0c572bac9dee324", false},
+ // must return first zstd instance with no platform
+ {"matchesImageWithNoPlatform", "", "sha256:f2f5f52a2cf2c51d4cac6df0545f751c0adc3f3427eb47c59fcb32894503e18f", false},
+ // must return first gzip instance with no platform
+ {"matchesImageWithNoPlatform", "", "sha256:c76757bb6006babdd8464dbf2f1157fdfa6fead0bc6f84f15816a32d6f68f706", true},
+ },
+ },
+ {
+ listFile: "oci1index.json",
+ matchedInstances: []expectedMatch{
+ {"amd64", "", "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270", false},
+ {"ppc64le", "", "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f", false},
+ },
+ unmatchedInstances: []string{
+ "unmatched",
+ },
+ },
+ } {
+ rawManifest, err := os.ReadFile(filepath.Join("testdata", manifestList.listFile))
+ require.NoError(t, err)
+ list, err := ListFromBlob(rawManifest, GuessMIMEType(rawManifest))
+ require.NoError(t, err)
+ for _, match := range manifestList.matchedInstances {
+ testName := fmt.Sprintf("%s %q+%q", manifestList.listFile, match.arch, match.variant)
+ digest, err := list.ChooseInstanceByCompression(&types.SystemContext{
+ ArchitectureChoice: match.arch,
+ VariantChoice: match.variant,
+ OSChoice: "linux",
+ }, types.NewOptionalBool(match.preferGzip))
+ require.NoError(t, err, testName)
+ assert.Equal(t, match.instanceDigest, digest, testName)
+ }
+ for _, arch := range manifestList.unmatchedInstances {
+ _, err := list.ChooseInstanceByCompression(&types.SystemContext{
+ ArchitectureChoice: arch,
+ OSChoice: "linux",
+ }, types.NewOptionalBool(false))
+ assert.Error(t, err)
+ }
+ }
+}
diff --git a/internal/manifest/testdata/non-json.manifest.json b/internal/manifest/testdata/non-json.manifest.json
new file mode 100644
index 0000000..f892721
--- /dev/null
+++ b/internal/manifest/testdata/non-json.manifest.json
Binary files differ
diff --git a/internal/manifest/testdata/oci1.index.zstd-selection.json b/internal/manifest/testdata/oci1.index.zstd-selection.json
new file mode 100644
index 0000000..a55e6b4
--- /dev/null
+++ b/internal/manifest/testdata/oci1.index.zstd-selection.json
@@ -0,0 +1,66 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "size": 758,
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "size": 772,
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ },
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "size": 758,
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "size": 758,
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "size": 758,
+ "platform": {
+ "architecture": "s390x",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
+ "size": 772,
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ },
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ }
+ ]
+}
diff --git a/internal/manifest/testdata/oci1.index.zstd-selection2.json b/internal/manifest/testdata/oci1.index.zstd-selection2.json
new file mode 100644
index 0000000..7153ccf
--- /dev/null
+++ b/internal/manifest/testdata/oci1.index.zstd-selection2.json
@@ -0,0 +1,96 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "size": 758,
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "size": 759,
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "size": 772,
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ },
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:1c98002b30a71b08ab175915ce7c8fb8da9e9b502ae082d6f0c572bac9dee324",
+ "size": 772,
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ },
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:c76757bb6006babdd8464dbf2f1157fdfa6fead0bc6f84f15816a32d6f68f706",
+ "size": 772
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:f2f5f52a2cf2c51d4cac6df0545f751c0adc3f3427eb47c59fcb32894503e18f",
+ "size": 772,
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:4612d27c6875f1dd3c28869dfd33c7be24f838261403cfb7940b76b6fd6ea4e2",
+ "size": 772
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:6dc14a60d2ba724646cfbf5fccbb9a618a5978a64a352e060b17caf5e005da9d",
+ "size": 772,
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "size": 759,
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "size": 772,
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ },
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ }
+ ]
+}
diff --git a/internal/manifest/testdata/oci1index.json b/internal/manifest/testdata/oci1index.json
new file mode 100644
index 0000000..a85b4d8
--- /dev/null
+++ b/internal/manifest/testdata/oci1index.json
@@ -0,0 +1,31 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 7143,
+ "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
+ "platform": {
+ "architecture": "ppc64le",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 7682,
+ "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux",
+ "os.features": [
+ "sse4"
+ ]
+ }
+ }
+ ],
+ "annotations": {
+ "com.example.key1": "value1",
+ "com.example.key2": "value2"
+ }
+}
diff --git a/internal/manifest/testdata/ocilist-variants.json b/internal/manifest/testdata/ocilist-variants.json
new file mode 100644
index 0000000..396ae04
--- /dev/null
+++ b/internal/manifest/testdata/ocilist-variants.json
@@ -0,0 +1,67 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 527,
+ "digest": "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 527,
+ "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "platform": {
+ "architecture": "arm",
+ "variant": "v7",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 527,
+ "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "platform": {
+ "architecture": "arm",
+ "variant": "v6",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 527,
+ "digest": "sha256:f365626a556e58189fc21d099fc64603db0f440bff07f77c740989515c544a39",
+ "annotations": {
+ "io.github.containers.compression.zstd": "true"
+ },
+ "platform": {
+ "architecture": "arm",
+ "variant": "v6",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 527,
+ "digest": "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990",
+ "platform": {
+ "architecture": "arm",
+ "variant": "unrecognized-present",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 527,
+ "digest": "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53",
+ "platform": {
+ "architecture": "arm",
+ "os": "linux"
+ }
+ }
+ ]
+}
diff --git a/internal/manifest/testdata/ociv1.artifact.json b/internal/manifest/testdata/ociv1.artifact.json
new file mode 100644
index 0000000..a538079
--- /dev/null
+++ b/internal/manifest/testdata/ociv1.artifact.json
@@ -0,0 +1,10 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.custom.artifact.config.v1+json",
+ "digest": "",
+ "size": 0
+ },
+ "layers": null
+}
diff --git a/internal/manifest/testdata/ociv1.image.index.json b/internal/manifest/testdata/ociv1.image.index.json
new file mode 100644
index 0000000..a85b4d8
--- /dev/null
+++ b/internal/manifest/testdata/ociv1.image.index.json
@@ -0,0 +1,31 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 7143,
+ "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
+ "platform": {
+ "architecture": "ppc64le",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 7682,
+ "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux",
+ "os.features": [
+ "sse4"
+ ]
+ }
+ }
+ ],
+ "annotations": {
+ "com.example.key1": "value1",
+ "com.example.key2": "value2"
+ }
+}
diff --git a/internal/manifest/testdata/ociv1.manifest.json b/internal/manifest/testdata/ociv1.manifest.json
new file mode 100644
index 0000000..7e2e2e8
--- /dev/null
+++ b/internal/manifest/testdata/ociv1.manifest.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 7023,
+ "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 32654,
+ "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 16724,
+ "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 73109,
+ "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
+ }
+ ],
+ "annotations": {
+ "com.example.key1": "value1",
+ "com.example.key2": "value2"
+ }
+}
diff --git a/internal/manifest/testdata/ociv1nomime.artifact.json b/internal/manifest/testdata/ociv1nomime.artifact.json
new file mode 100644
index 0000000..5091d1f
--- /dev/null
+++ b/internal/manifest/testdata/ociv1nomime.artifact.json
@@ -0,0 +1,9 @@
+{
+ "schemaVersion": 2,
+ "config": {
+ "mediaType": "application/vnd.oci.custom.artifact.config.v1+json",
+ "digest": "",
+ "size": 0
+ },
+ "layers": null
+}
diff --git a/internal/manifest/testdata/ociv1nomime.image.index.json b/internal/manifest/testdata/ociv1nomime.image.index.json
new file mode 100644
index 0000000..066f058
--- /dev/null
+++ b/internal/manifest/testdata/ociv1nomime.image.index.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 2,
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 7143,
+ "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
+ "platform": {
+ "architecture": "ppc64le",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "size": 7682,
+ "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux",
+ "os.features": [
+ "sse4"
+ ]
+ }
+ }
+ ],
+ "annotations": {
+ "com.example.key1": "value1",
+ "com.example.key2": "value2"
+ }
+}
diff --git a/internal/manifest/testdata/ociv1nomime.manifest.json b/internal/manifest/testdata/ociv1nomime.manifest.json
new file mode 100644
index 0000000..1e1047c
--- /dev/null
+++ b/internal/manifest/testdata/ociv1nomime.manifest.json
@@ -0,0 +1,29 @@
+{
+ "schemaVersion": 2,
+ "config": {
+ "mediaType": "application/vnd.oci.image.config.v1+json",
+ "size": 7023,
+ "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 32654,
+ "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 16724,
+ "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
+ },
+ {
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "size": 73109,
+ "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
+ }
+ ],
+ "annotations": {
+ "com.example.key1": "value1",
+ "com.example.key2": "value2"
+ }
+}
diff --git a/internal/manifest/testdata/schema2-to-schema1-by-docker.json b/internal/manifest/testdata/schema2-to-schema1-by-docker.json
new file mode 120000
index 0000000..437a6be
--- /dev/null
+++ b/internal/manifest/testdata/schema2-to-schema1-by-docker.json
@@ -0,0 +1 @@
+../../../internal/image/fixtures/schema2-to-schema1-by-docker.json \ No newline at end of file
diff --git a/internal/manifest/testdata/schema2list-variants.json b/internal/manifest/testdata/schema2list-variants.json
new file mode 100644
index 0000000..0f5214c
--- /dev/null
+++ b/internal/manifest/testdata/schema2list-variants.json
@@ -0,0 +1,44 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:f365626a556e58189fc21d099fc64603db0f440bff07f77c740989515c544a39",
+ "platform": {
+ "architecture": "arm",
+ "variant": "v6",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990",
+ "platform": {
+ "architecture": "arm",
+ "variant": "unrecognized-present",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53",
+ "platform": {
+ "architecture": "arm",
+ "os": "linux"
+ }
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/manifest/testdata/schema2list.json b/internal/manifest/testdata/schema2list.json
new file mode 100644
index 0000000..398b746
--- /dev/null
+++ b/internal/manifest/testdata/schema2list.json
@@ -0,0 +1,72 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:030fcb92e1487b18c974784dcc110a93147c9fc402188370fbfd17efabffc6af",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:9142d97ef280a7953cf1a85716de49a24cc1dd62776352afad67e635331ff77a",
+ "platform": {
+ "architecture": "arm",
+ "os": "linux",
+ "variant": "v5"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:b5dbad4bdb4444d919294afe49a095c23e86782f98cdf0aa286198ddb814b50b",
+ "platform": {
+ "architecture": "arm",
+ "os": "linux",
+ "variant": "v6"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:dc472a59fb006797aa2a6bfb54cc9c57959bb0a6d11fadaa608df8c16dea39cf",
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux",
+ "variant": "v8"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 527,
+ "digest": "sha256:9a33b9909e56b0a2092a65fb1b79ef6717fa160b1f084476b860418780e8d53b",
+ "platform": {
+ "architecture": "386",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 528,
+ "digest": "sha256:59117d7c016fba6ede7f87991204bd672a1dca444102de66db632383507ed90b",
+ "platform": {
+ "architecture": "ppc64le",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "size": 528,
+ "digest": "sha256:e5aa1b0a24620228b75382997a0977f609b3ca3a95533dafdef84c74cc8df642",
+ "platform": {
+ "architecture": "s390x",
+ "os": "linux"
+ }
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/manifest/testdata/unknown-version.manifest.json b/internal/manifest/testdata/unknown-version.manifest.json
new file mode 100644
index 0000000..b0f34b6
--- /dev/null
+++ b/internal/manifest/testdata/unknown-version.manifest.json
@@ -0,0 +1,5 @@
+{
+ "schemaVersion": 99999,
+ "name": "mitr/noversion-nonsense",
+ "tag": "latest"
+}
diff --git a/internal/manifest/testdata/v2list.manifest.json b/internal/manifest/testdata/v2list.manifest.json
new file mode 100644
index 0000000..1bf9896
--- /dev/null
+++ b/internal/manifest/testdata/v2list.manifest.json
@@ -0,0 +1,56 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v1+json",
+ "size": 2094,
+ "digest": "sha256:7820f9a86d4ad15a2c4f0c0e5479298df2aa7c2f6871288e2ef8546f3e7b6783",
+ "platform": {
+ "architecture": "ppc64le",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v1+json",
+ "size": 1922,
+ "digest": "sha256:ae1b0e06e8ade3a11267564a26e750585ba2259c0ecab59ab165ad1af41d1bdd",
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux",
+ "features": [
+ "sse"
+ ]
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v1+json",
+ "size": 2084,
+ "digest": "sha256:e4c0df75810b953d6717b8f8f28298d73870e8aa2a0d5e77b8391f16fdfbbbe2",
+ "platform": {
+ "architecture": "s390x",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v1+json",
+ "size": 2084,
+ "digest": "sha256:07ebe243465ef4a667b78154ae6c3ea46fdb1582936aac3ac899ea311a701b40",
+ "platform": {
+ "architecture": "arm",
+ "os": "linux",
+ "variant": "armv7"
+ }
+ },
+ {
+ "mediaType": "application/vnd.docker.distribution.manifest.v1+json",
+ "size": 2090,
+ "digest": "sha256:fb2fc0707b86dafa9959fe3d29e66af8787aee4d9a23581714be65db4265ad8a",
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux",
+ "variant": "armv8"
+ }
+ }
+ ]
+}
diff --git a/internal/manifest/testdata/v2s1-invalid-signatures.manifest.json b/internal/manifest/testdata/v2s1-invalid-signatures.manifest.json
new file mode 100644
index 0000000..96def40
--- /dev/null
+++ b/internal/manifest/testdata/v2s1-invalid-signatures.manifest.json
@@ -0,0 +1,11 @@
+{
+ "schemaVersion": 1,
+ "name": "mitr/busybox",
+ "tag": "latest",
+ "architecture": "amd64",
+ "fsLayers": [
+ ],
+ "history": [
+ ],
+ "signatures": 1
+}
diff --git a/internal/manifest/testdata/v2s1-unsigned.manifest.json b/internal/manifest/testdata/v2s1-unsigned.manifest.json
new file mode 100644
index 0000000..16764b4
--- /dev/null
+++ b/internal/manifest/testdata/v2s1-unsigned.manifest.json
@@ -0,0 +1,28 @@
+{
+ "schemaVersion": 1,
+ "name": "mitr/busybox",
+ "tag": "latest",
+ "architecture": "amd64",
+ "fsLayers": [
+ {
+ "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
+ },
+ {
+ "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
+ },
+ {
+ "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
+ }
+ ],
+ "history": [
+ {
+ "v1Compatibility": "{\"id\":\"f1b5eb0a1215f663765d509b6cdf3841bc2bcff0922346abb943d1342d469a97\",\"parent\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"created\":\"2016-03-03T11:29:44.222098366Z\",\"container\":\"c0924f5b281a1992127d0afc065e59548ded8880b08aea4debd56d4497acb17a\",\"container_config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) LABEL Checksum=4fef81d30f31f9213c642881357e6662846a0f884c2366c13ebad807b4031368 ./tests/test-images/Dockerfile.2\"],\"Image\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Checksum\":\"4fef81d30f31f9213c642881357e6662846a0f884c2366c13ebad807b4031368 ./tests/test-images/Dockerfile.2\",\"Name\":\"atomic-test-2\"}},\"docker_version\":\"1.8.2-fc22\",\"author\":\"\\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\",\"config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Checksum\":\"4fef81d30f31f9213c642881357e6662846a0f884c2366c13ebad807b4031368 ./tests/test-images/Dockerfile.2\",\"Name\":\"atomic-test-2\"}},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":0}\n"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"parent\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"created\":\"2016-03-03T11:29:38.563048924Z\",\"container\":\"fd4cf54dcd239fbae9bdade9db48e41880b436d27cb5313f60952a46ab04deff\",\"container_config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) LABEL Name=atomic-test-2\"],\"Image\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Name\":\"atomic-test-2\"}},\"docker_version\":\"1.8.2-fc22\",\"author\":\"\\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\",\"config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Name\":\"atomic-test-2\"}},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":0}\n"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"created\":\"2016-03-03T11:29:32.948089874Z\",\"container\":\"56f0fe1dfc95755dd6cda10f7215c9937a8d9c6348d079c581a261fd4c2f3a5f\",\"container_config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) MAINTAINER \\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\"],\"Image\":\"\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.2-fc22\",\"author\":\"\\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\",\"config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":0}\n"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/manifest/testdata/v2s1.manifest.json b/internal/manifest/testdata/v2s1.manifest.json
new file mode 100644
index 0000000..f7bcd07
--- /dev/null
+++ b/internal/manifest/testdata/v2s1.manifest.json
@@ -0,0 +1,44 @@
+{
+ "schemaVersion": 1,
+ "name": "mitr/busybox",
+ "tag": "latest",
+ "architecture": "amd64",
+ "fsLayers": [
+ {
+ "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
+ },
+ {
+ "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
+ },
+ {
+ "blobSum": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
+ }
+ ],
+ "history": [
+ {
+ "v1Compatibility": "{\"id\":\"f1b5eb0a1215f663765d509b6cdf3841bc2bcff0922346abb943d1342d469a97\",\"parent\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"created\":\"2016-03-03T11:29:44.222098366Z\",\"container\":\"c0924f5b281a1992127d0afc065e59548ded8880b08aea4debd56d4497acb17a\",\"container_config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) LABEL Checksum=4fef81d30f31f9213c642881357e6662846a0f884c2366c13ebad807b4031368 ./tests/test-images/Dockerfile.2\"],\"Image\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Checksum\":\"4fef81d30f31f9213c642881357e6662846a0f884c2366c13ebad807b4031368 ./tests/test-images/Dockerfile.2\",\"Name\":\"atomic-test-2\"}},\"docker_version\":\"1.8.2-fc22\",\"author\":\"\\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\",\"config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Checksum\":\"4fef81d30f31f9213c642881357e6662846a0f884c2366c13ebad807b4031368 ./tests/test-images/Dockerfile.2\",\"Name\":\"atomic-test-2\"}},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":0}\n"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"594075be8d003f784074cc639d970d1fa091a8197850baaae5052c01564ac535\",\"parent\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"created\":\"2016-03-03T11:29:38.563048924Z\",\"container\":\"fd4cf54dcd239fbae9bdade9db48e41880b436d27cb5313f60952a46ab04deff\",\"container_config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) LABEL Name=atomic-test-2\"],\"Image\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Name\":\"atomic-test-2\"}},\"docker_version\":\"1.8.2-fc22\",\"author\":\"\\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\",\"config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":{\"Name\":\"atomic-test-2\"}},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":0}\n"
+ },
+ {
+ "v1Compatibility": "{\"id\":\"03dfa1cd1abe452bc2b69b8eb2362fa6beebc20893e65437906318954f6276d4\",\"created\":\"2016-03-03T11:29:32.948089874Z\",\"container\":\"56f0fe1dfc95755dd6cda10f7215c9937a8d9c6348d079c581a261fd4c2f3a5f\",\"container_config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) MAINTAINER \\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\"],\"Image\":\"\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.2-fc22\",\"author\":\"\\\"William Temple \\u003cwtemple at redhat dot com\\u003e\\\"\",\"config\":{\"Hostname\":\"56f0fe1dfc95\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"ExposedPorts\":null,\"PublishService\":\"\",\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"VolumeDriver\":\"\",\"WorkingDir\":\"\",\"Entrypoint\":null,\"NetworkDisabled\":false,\"MacAddress\":\"\",\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":0}\n"
+ }
+ ],
+ "signatures": [
+ {
+ "header": {
+ "jwk": {
+ "crv": "P-256",
+ "kid": "OZ45:U3IG:TDOI:PMBD:NGP2:LDIW:II2U:PSBI:MMCZ:YZUP:TUUO:XPZT",
+ "kty": "EC",
+ "x": "ReC5c0J9tgXSdUL4_xzEt5RsD8kFt2wWSgJcpAcOQx8",
+ "y": "3sBGEqQ3ZMeqPKwQBAadN2toOUEASha18xa0WwsDF-M"
+ },
+ "alg": "ES256"
+ },
+ "signature": "dV1paJ3Ck1Ph4FcEhg_frjqxdlGdI6-ywRamk6CvMOcaOEUdCWCpCPQeBQpD2N6tGjkoG1BbstkFNflllfenCw",
+ "protected": "eyJmb3JtYXRMZW5ndGgiOjU0NzgsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNi0wNC0xOFQyMDo1NDo0MloifQ"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/manifest/testdata/v2s2.manifest.json b/internal/manifest/testdata/v2s2.manifest.json
new file mode 100644
index 0000000..198da23
--- /dev/null
+++ b/internal/manifest/testdata/v2s2.manifest.json
@@ -0,0 +1,26 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": 7023,
+ "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
+ },
+ "layers": [
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 32654,
+ "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 16724,
+ "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
+ },
+ {
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 73109,
+ "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
+ }
+ ]
+} \ No newline at end of file
diff --git a/internal/manifest/testdata/v2s2nomime.manifest.json b/internal/manifest/testdata/v2s2nomime.manifest.json
new file mode 100644
index 0000000..a0b06c2
--- /dev/null
+++ b/internal/manifest/testdata/v2s2nomime.manifest.json
@@ -0,0 +1,10 @@
+{
+ "schemaVersion": 2,
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": 7023,
+ "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
+ },
+ "layers": [
+ ]
+}
diff --git a/internal/manifest/testdata_info_test.go b/internal/manifest/testdata_info_test.go
new file mode 100644
index 0000000..bfdaed1
--- /dev/null
+++ b/internal/manifest/testdata_info_test.go
@@ -0,0 +1,12 @@
+package manifest
+
+import "github.com/opencontainers/go-digest"
+
+const (
+ // TestV2S2ManifestDigest is the Docker manifest digest of "v2s2.manifest.json"
+ TestDockerV2S2ManifestDigest = digest.Digest("sha256:20bf21ed457b390829cdbeec8795a7bea1626991fda603e0d01b4e7f60427e55")
+ // TestV2S1ManifestDigest is the Docker manifest digest of "v2s1.manifest.json"
+ TestDockerV2S1ManifestDigest = digest.Digest("sha256:7364fea9d84ee548ab67d4c46c6006289800c98de3fbf8c0a97138dfcc23f000")
+ // TestV2S1UnsignedManifestDigest is the Docker manifest digest of "v2s1unsigned.manifest.json"
+ TestDockerV2S1UnsignedManifestDigest = digest.Digest("sha256:7364fea9d84ee548ab67d4c46c6006289800c98de3fbf8c0a97138dfcc23f000")
+)
diff --git a/internal/pkg/platform/platform_matcher.go b/internal/pkg/platform/platform_matcher.go
new file mode 100644
index 0000000..3ba0e40
--- /dev/null
+++ b/internal/pkg/platform/platform_matcher.go
@@ -0,0 +1,197 @@
+package platform
+
+// Largely based on
+// https://github.com/moby/moby/blob/bc846d2e8fe5538220e0c31e9d0e8446f6fbc022/distribution/cpuinfo_unix.go
+// Copyright 2012-2017 Docker, Inc.
+//
+// https://github.com/containerd/containerd/blob/726dcaea50883e51b2ec6db13caff0e7936b711d/platforms/cpuinfo.go
+// Copyright The containerd 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
+// https://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.
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+
+ "github.com/containers/image/v5/types"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "golang.org/x/exp/slices"
+)
+
+// For Linux, the kernel has already detected the ABI, ISA and Features.
+// So we don't need to access the ARM registers to detect platform information
+// by ourselves. We can just parse these information from /proc/cpuinfo
+func getCPUInfo(pattern string) (info string, err error) {
+ if runtime.GOOS != "linux" {
+ return "", fmt.Errorf("getCPUInfo for OS %s not implemented", runtime.GOOS)
+ }
+
+ cpuinfo, err := os.Open("/proc/cpuinfo")
+ if err != nil {
+ return "", err
+ }
+ defer cpuinfo.Close()
+
+ // Start to Parse the Cpuinfo line by line. For SMP SoC, we parse
+ // the first core is enough.
+ scanner := bufio.NewScanner(cpuinfo)
+ for scanner.Scan() {
+ newline := scanner.Text()
+ list := strings.Split(newline, ":")
+
+ if len(list) > 1 && strings.EqualFold(strings.TrimSpace(list[0]), pattern) {
+ return strings.TrimSpace(list[1]), nil
+ }
+ }
+
+ // Check whether the scanner encountered errors
+ err = scanner.Err()
+ if err != nil {
+ return "", err
+ }
+
+ return "", fmt.Errorf("getCPUInfo for pattern: %s not found", pattern)
+}
+
+func getCPUVariantWindows(arch string) string {
+ // Windows only supports v7 for ARM32 and v8 for ARM64 and so we can use
+ // runtime.GOARCH to determine the variants
+ var variant string
+ switch arch {
+ case "arm64":
+ variant = "v8"
+ case "arm":
+ variant = "v7"
+ default:
+ variant = ""
+ }
+
+ return variant
+}
+
+func getCPUVariantArm() string {
+ variant, err := getCPUInfo("Cpu architecture")
+ if err != nil {
+ return ""
+ }
+ // TODO handle RPi Zero mismatch (https://github.com/moby/moby/pull/36121#issuecomment-398328286)
+
+ switch strings.ToLower(variant) {
+ case "8", "aarch64":
+ variant = "v8"
+ case "7", "7m", "?(12)", "?(13)", "?(14)", "?(15)", "?(16)", "?(17)":
+ variant = "v7"
+ case "6", "6tej":
+ variant = "v6"
+ case "5", "5t", "5te", "5tej":
+ variant = "v5"
+ case "4", "4t":
+ variant = "v4"
+ case "3":
+ variant = "v3"
+ default:
+ variant = ""
+ }
+
+ return variant
+}
+
+func getCPUVariant(os string, arch string) string {
+ if os == "windows" {
+ return getCPUVariantWindows(arch)
+ }
+ if arch == "arm" || arch == "arm64" {
+ return getCPUVariantArm()
+ }
+ return ""
+}
+
+// compatibility contains, for a specified architecture, a list of known variants, in the
+// order from most capable (most restrictive) to least capable (most compatible).
+// Architectures that don’t have variants should not have an entry here.
+var compatibility = map[string][]string{
+ "arm": {"v8", "v7", "v6", "v5"},
+ "arm64": {"v8"},
+}
+
+// WantedPlatforms returns all compatible platforms with the platform specifics possibly overridden by user,
+// the most compatible platform is first.
+// If some option (arch, os, variant) is not present, a value from current platform is detected.
+func WantedPlatforms(ctx *types.SystemContext) ([]imgspecv1.Platform, error) {
+ // Note that this does not use Platform.OSFeatures and Platform.OSVersion at all.
+ // The fields are not specified by the OCI specification, as of version 1.1, usefully enough
+ // to be interoperable, anyway.
+
+ wantedArch := runtime.GOARCH
+ wantedVariant := ""
+ if ctx != nil && ctx.ArchitectureChoice != "" {
+ wantedArch = ctx.ArchitectureChoice
+ } else {
+ // Only auto-detect the variant if we are using the default architecture.
+ // If the user has specified the ArchitectureChoice, don't autodetect, even if
+ // ctx.ArchitectureChoice == runtime.GOARCH, because we have no idea whether the runtime.GOARCH
+ // value is relevant to the use case, and if we do autodetect a variant,
+ // ctx.VariantChoice can't be used to override it back to "".
+ wantedVariant = getCPUVariant(runtime.GOOS, runtime.GOARCH)
+ }
+ if ctx != nil && ctx.VariantChoice != "" {
+ wantedVariant = ctx.VariantChoice
+ }
+
+ wantedOS := runtime.GOOS
+ if ctx != nil && ctx.OSChoice != "" {
+ wantedOS = ctx.OSChoice
+ }
+
+ var variants []string = nil
+ if wantedVariant != "" {
+ // If the user requested a specific variant, we'll walk down
+ // the list from most to least compatible.
+ if variantOrder := compatibility[wantedArch]; variantOrder != nil {
+ if i := slices.Index(variantOrder, wantedVariant); i != -1 {
+ variants = variantOrder[i:]
+ }
+ }
+ if variants == nil {
+ // user wants a variant which we know nothing about - not even compatibility
+ variants = []string{wantedVariant}
+ }
+ // Make sure to have a candidate with an empty variant as well.
+ variants = append(variants, "")
+ } else {
+ // Make sure to have a candidate with an empty variant as well.
+ variants = append(variants, "")
+ // If available add the entire compatibility matrix for the specific architecture.
+ if possibleVariants, ok := compatibility[wantedArch]; ok {
+ variants = append(variants, possibleVariants...)
+ }
+ }
+
+ res := make([]imgspecv1.Platform, 0, len(variants))
+ for _, v := range variants {
+ res = append(res, imgspecv1.Platform{
+ OS: wantedOS,
+ Architecture: wantedArch,
+ Variant: v,
+ })
+ }
+ return res, nil
+}
+
+// MatchesPlatform returns true if a platform descriptor from a multi-arch image matches
+// an item from the return value of WantedPlatforms.
+func MatchesPlatform(image imgspecv1.Platform, wanted imgspecv1.Platform) bool {
+ return image.Architecture == wanted.Architecture &&
+ image.OS == wanted.OS &&
+ image.Variant == wanted.Variant
+}
diff --git a/internal/pkg/platform/platform_matcher_test.go b/internal/pkg/platform/platform_matcher_test.go
new file mode 100644
index 0000000..9647a34
--- /dev/null
+++ b/internal/pkg/platform/platform_matcher_test.go
@@ -0,0 +1,61 @@
+package platform
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/containers/image/v5/types"
+ imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWantedPlatforms(t *testing.T) {
+ for _, c := range []struct {
+ ctx types.SystemContext
+ expected []imgspecv1.Platform
+ }{
+ { // X86_64 does not have variants
+ types.SystemContext{ArchitectureChoice: "amd64", OSChoice: "linux"},
+ []imgspecv1.Platform{
+ {OS: "linux", Architecture: "amd64", Variant: ""},
+ },
+ },
+ { // ARM with variant
+ types.SystemContext{ArchitectureChoice: "arm", OSChoice: "linux", VariantChoice: "v6"},
+ []imgspecv1.Platform{
+ {OS: "linux", Architecture: "arm", Variant: "v6"},
+ {OS: "linux", Architecture: "arm", Variant: "v5"},
+ {OS: "linux", Architecture: "arm", Variant: ""},
+ },
+ },
+ { // ARM without variant
+ types.SystemContext{ArchitectureChoice: "arm", OSChoice: "linux"},
+ []imgspecv1.Platform{
+ {OS: "linux", Architecture: "arm", Variant: ""},
+ {OS: "linux", Architecture: "arm", Variant: "v8"},
+ {OS: "linux", Architecture: "arm", Variant: "v7"},
+ {OS: "linux", Architecture: "arm", Variant: "v6"},
+ {OS: "linux", Architecture: "arm", Variant: "v5"},
+ },
+ },
+ { // ARM64 has a base variant
+ types.SystemContext{ArchitectureChoice: "arm64", OSChoice: "linux"},
+ []imgspecv1.Platform{
+ {OS: "linux", Architecture: "arm64", Variant: ""},
+ {OS: "linux", Architecture: "arm64", Variant: "v8"},
+ },
+ },
+ { // Custom (completely unrecognized data)
+ types.SystemContext{ArchitectureChoice: "armel", OSChoice: "freeBSD", VariantChoice: "custom"},
+ []imgspecv1.Platform{
+ {OS: "freeBSD", Architecture: "armel", Variant: "custom"},
+ {OS: "freeBSD", Architecture: "armel", Variant: ""},
+ },
+ },
+ } {
+ testName := fmt.Sprintf("%q/%q/%q", c.ctx.ArchitectureChoice, c.ctx.OSChoice, c.ctx.VariantChoice)
+ platforms, err := WantedPlatforms(&c.ctx)
+ assert.Nil(t, err, testName)
+ assert.Equal(t, c.expected, platforms, testName)
+ }
+}
diff --git a/internal/private/private.go b/internal/private/private.go
new file mode 100644
index 0000000..95d561f
--- /dev/null
+++ b/internal/private/private.go
@@ -0,0 +1,164 @@
+package private
+
+import (
+ "context"
+ "io"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/blobinfocache"
+ "github.com/containers/image/v5/internal/signature"
+ compression "github.com/containers/image/v5/pkg/compression/types"
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// ImageSourceInternalOnly is the part of private.ImageSource that is not
+// a part of types.ImageSource.
+type ImageSourceInternalOnly interface {
+ // SupportsGetBlobAt() returns true if GetBlobAt (BlobChunkAccessor) is supported.
+ SupportsGetBlobAt() bool
+ // BlobChunkAccessor.GetBlobAt is available only if SupportsGetBlobAt().
+ BlobChunkAccessor
+
+ // GetSignaturesWithFormat returns the image's signatures. It may use a remote (= slow) service.
+ // If instanceDigest is not nil, it contains a digest of the specific manifest instance to retrieve signatures for
+ // (when the primary manifest is a manifest list); this never happens if the primary manifest is not a manifest list
+ // (e.g. if the source never returns manifest lists).
+ GetSignaturesWithFormat(ctx context.Context, instanceDigest *digest.Digest) ([]signature.Signature, error)
+}
+
+// ImageSource is an internal extension to the types.ImageSource interface.
+type ImageSource interface {
+ types.ImageSource
+ ImageSourceInternalOnly
+}
+
+// ImageDestinationInternalOnly is the part of private.ImageDestination that is not
+// a part of types.ImageDestination.
+type ImageDestinationInternalOnly interface {
+ // SupportsPutBlobPartial returns true if PutBlobPartial is supported.
+ SupportsPutBlobPartial() bool
+ // FIXME: Add SupportsSignaturesWithFormat or something like that, to allow early failures
+ // on unsupported formats.
+
+ // PutBlobWithOptions writes contents of stream and returns data representing the result.
+ // inputInfo.Digest can be optionally provided if known; if provided, and stream is read to the end without error, the digest MUST match the stream contents.
+ // inputInfo.Size is the expected length of stream, if known.
+ // inputInfo.MediaType describes the blob format, if known.
+ // WARNING: The contents of stream are being verified on the fly. Until stream.Read() returns io.EOF, the contents of the data SHOULD NOT be available
+ // to any other readers for download using the supplied digest.
+ // If stream.Read() at any time, ESPECIALLY at end of input, returns an error, PutBlobWithOptions MUST 1) fail, and 2) delete any data stored so far.
+ PutBlobWithOptions(ctx context.Context, stream io.Reader, inputInfo types.BlobInfo, options PutBlobOptions) (UploadedBlob, error)
+
+ // PutBlobPartial attempts to create a blob using the data that is already present
+ // at the destination. chunkAccessor is accessed in a non-sequential way to retrieve the missing chunks.
+ // It is available only if SupportsPutBlobPartial().
+ // Even if SupportsPutBlobPartial() returns true, the call can fail, in which case the caller
+ // should fall back to PutBlobWithOptions.
+ PutBlobPartial(ctx context.Context, chunkAccessor BlobChunkAccessor, srcInfo types.BlobInfo, cache blobinfocache.BlobInfoCache2) (UploadedBlob, error)
+
+ // TryReusingBlobWithOptions checks whether the transport already contains, or can efficiently reuse, a blob, and if so, applies it to the current destination
+ // (e.g. if the blob is a filesystem layer, this signifies that the changes it describes need to be applied again when composing a filesystem tree).
+ // info.Digest must not be empty.
+ // If the blob has been successfully reused, returns (true, info, nil).
+ // If the transport can not reuse the requested blob, TryReusingBlob returns (false, {}, nil); it returns a non-nil error only on an unexpected failure.
+ TryReusingBlobWithOptions(ctx context.Context, info types.BlobInfo, options TryReusingBlobOptions) (bool, ReusedBlob, error)
+
+ // PutSignaturesWithFormat writes a set of signatures to the destination.
+ // If instanceDigest is not nil, it contains a digest of the specific manifest instance to write or overwrite the signatures for
+ // (when the primary manifest is a manifest list); this should always be nil if the primary manifest is not a manifest list.
+ // MUST be called after PutManifest (signatures may reference manifest contents).
+ PutSignaturesWithFormat(ctx context.Context, signatures []signature.Signature, instanceDigest *digest.Digest) error
+}
+
+// ImageDestination is an internal extension to the types.ImageDestination
+// interface.
+type ImageDestination interface {
+ types.ImageDestination
+ ImageDestinationInternalOnly
+}
+
+// UploadedBlob is information about a blob written to a destination.
+// It is the subset of types.BlobInfo fields the transport is responsible for setting; all fields must be provided.
+type UploadedBlob struct {
+ Digest digest.Digest
+ Size int64
+}
+
+// PutBlobOptions are used in PutBlobWithOptions.
+type PutBlobOptions struct {
+ Cache blobinfocache.BlobInfoCache2 // Cache to optionally update with the uploaded bloblook up blob infos.
+ IsConfig bool // True if the blob is a config
+
+ // The following fields are new to internal/private. Users of internal/private MUST fill them in,
+ // but they also must expect that they will be ignored by types.ImageDestination transports.
+ // Transports, OTOH, MUST support these fields being zero-valued for types.ImageDestination callers
+ // if they use internal/imagedestination/impl.Compat;
+ // in that case, they will all be consistently zero-valued.
+
+ EmptyLayer bool // True if the blob is an "empty"/"throwaway" layer, and may not necessarily be physically represented.
+ LayerIndex *int // If the blob is a layer, a zero-based index of the layer within the image; nil otherwise.
+}
+
+// TryReusingBlobOptions are used in TryReusingBlobWithOptions.
+type TryReusingBlobOptions struct {
+ Cache blobinfocache.BlobInfoCache2 // Cache to use and/or update.
+ // If true, it is allowed to use an equivalent of the desired blob;
+ // in that case the returned info may not match the input.
+ CanSubstitute bool
+
+ // The following fields are new to internal/private. Users of internal/private MUST fill them in,
+ // but they also must expect that they will be ignored by types.ImageDestination transports.
+ // Transports, OTOH, MUST support these fields being zero-valued for types.ImageDestination callers
+ // if they use internal/imagedestination/impl.Compat;
+ // in that case, they will all be consistently zero-valued.
+ RequiredCompression *compression.Algorithm // If set, reuse blobs with a matching algorithm as per implementations in internal/imagedestination/impl.helpers.go
+ OriginalCompression *compression.Algorithm // Must be set if RequiredCompression is set; can be set to nil to indicate “uncompressed” or “unknown”.
+ EmptyLayer bool // True if the blob is an "empty"/"throwaway" layer, and may not necessarily be physically represented.
+ LayerIndex *int // If the blob is a layer, a zero-based index of the layer within the image; nil otherwise.
+ SrcRef reference.Named // A reference to the source image that contains the input blob.
+}
+
+// ReusedBlob is information about a blob reused in a destination.
+// It is the subset of types.BlobInfo fields the transport is responsible for setting.
+type ReusedBlob struct {
+ Digest digest.Digest // Must be provided
+ Size int64 // Must be provided
+ // The following compression fields should be set when the reuse substitutes
+ // a differently-compressed blob.
+ CompressionOperation types.LayerCompression // Compress/Decompress, matching the reused blob; PreserveOriginal if N/A
+ CompressionAlgorithm *compression.Algorithm // Algorithm if compressed, nil if decompressed or N/A
+}
+
+// ImageSourceChunk is a portion of a blob.
+// This API is experimental and can be changed without bumping the major version number.
+type ImageSourceChunk struct {
+ Offset uint64
+ Length uint64
+}
+
+// BlobChunkAccessor allows fetching discontiguous chunks of a blob.
+type BlobChunkAccessor interface {
+ // GetBlobAt returns a sequential channel of readers that contain data for the requested
+ // blob chunks, and a channel that might get a single error value.
+ // The specified chunks must be not overlapping and sorted by their offset.
+ // The readers must be fully consumed, in the order they are returned, before blocking
+ // to read the next chunk.
+ GetBlobAt(ctx context.Context, info types.BlobInfo, chunks []ImageSourceChunk) (chan io.ReadCloser, chan error, error)
+}
+
+// BadPartialRequestError is returned by BlobChunkAccessor.GetBlobAt on an invalid request.
+type BadPartialRequestError struct {
+ Status string
+}
+
+func (e BadPartialRequestError) Error() string {
+ return e.Status
+}
+
+// UnparsedImage is an internal extension to the types.UnparsedImage interface.
+type UnparsedImage interface {
+ types.UnparsedImage
+ // UntrustedSignatures is like ImageSource.GetSignaturesWithFormat, but the result is cached; it is OK to call this however often you need.
+ UntrustedSignatures(ctx context.Context) ([]signature.Signature, error)
+}
diff --git a/internal/putblobdigest/put_blob_digest.go b/internal/putblobdigest/put_blob_digest.go
new file mode 100644
index 0000000..b8d3a7e
--- /dev/null
+++ b/internal/putblobdigest/put_blob_digest.go
@@ -0,0 +1,57 @@
+package putblobdigest
+
+import (
+ "io"
+
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// Digester computes a digest of the provided stream, if not known yet.
+type Digester struct {
+ knownDigest digest.Digest // Or ""
+ digester digest.Digester // Or nil
+}
+
+// newDigester initiates computation of a digest.Canonical digest of stream,
+// if !validDigest; otherwise it just records knownDigest to be returned later.
+// The caller MUST use the returned stream instead of the original value.
+func newDigester(stream io.Reader, knownDigest digest.Digest, validDigest bool) (Digester, io.Reader) {
+ if validDigest {
+ return Digester{knownDigest: knownDigest}, stream
+ } else {
+ res := Digester{
+ digester: digest.Canonical.Digester(),
+ }
+ stream = io.TeeReader(stream, res.digester.Hash())
+ return res, stream
+ }
+}
+
+// DigestIfUnknown initiates computation of a digest.Canonical digest of stream,
+// if no digest is supplied in the provided blobInfo; otherwise blobInfo.Digest will
+// be used (accepting any algorithm).
+// The caller MUST use the returned stream instead of the original value.
+func DigestIfUnknown(stream io.Reader, blobInfo types.BlobInfo) (Digester, io.Reader) {
+ d := blobInfo.Digest
+ return newDigester(stream, d, d != "")
+}
+
+// DigestIfCanonicalUnknown initiates computation of a digest.Canonical digest of stream,
+// if a digest.Canonical digest is not supplied in the provided blobInfo;
+// otherwise blobInfo.Digest will be used.
+// The caller MUST use the returned stream instead of the original value.
+func DigestIfCanonicalUnknown(stream io.Reader, blobInfo types.BlobInfo) (Digester, io.Reader) {
+ d := blobInfo.Digest
+ return newDigester(stream, d, d != "" && d.Algorithm() == digest.Canonical)
+}
+
+// Digest() returns a digest value possibly computed by Digester.
+// This must be called only after all of the stream returned by a Digester constructor
+// has been successfully read.
+func (d Digester) Digest() digest.Digest {
+ if d.digester != nil {
+ return d.digester.Digest()
+ }
+ return d.knownDigest
+}
diff --git a/internal/putblobdigest/put_blob_digest_test.go b/internal/putblobdigest/put_blob_digest_test.go
new file mode 100644
index 0000000..eb8ebbc
--- /dev/null
+++ b/internal/putblobdigest/put_blob_digest_test.go
@@ -0,0 +1,74 @@
+package putblobdigest
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testData = []byte("test data")
+
+type testCase struct {
+ inputDigest digest.Digest
+ computesDigest bool
+ expectedDigest digest.Digest
+}
+
+func testDigester(t *testing.T, constructor func(io.Reader, types.BlobInfo) (Digester, io.Reader),
+ cases []testCase) {
+ for _, c := range cases {
+ stream := bytes.NewReader(testData)
+ digester, newStream := constructor(stream, types.BlobInfo{Digest: c.inputDigest})
+ assert.Equal(t, c.computesDigest, newStream != stream, c.inputDigest)
+ data, err := io.ReadAll(newStream)
+ require.NoError(t, err, c.inputDigest)
+ assert.Equal(t, testData, data, c.inputDigest)
+ digest := digester.Digest()
+ assert.Equal(t, c.expectedDigest, digest, c.inputDigest)
+ }
+}
+
+func TestDigestIfUnknown(t *testing.T) {
+ testDigester(t, DigestIfUnknown, []testCase{
+ {
+ inputDigest: digest.Digest("sha256:uninspected-value"),
+ computesDigest: false,
+ expectedDigest: digest.Digest("sha256:uninspected-value"),
+ },
+ {
+ inputDigest: digest.Digest("unknown-algorithm:uninspected-value"),
+ computesDigest: false,
+ expectedDigest: digest.Digest("unknown-algorithm:uninspected-value"),
+ },
+ {
+ inputDigest: "",
+ computesDigest: true,
+ expectedDigest: digest.Canonical.FromBytes(testData),
+ },
+ })
+}
+
+func TestDigestIfCanonicalUnknown(t *testing.T) {
+ testDigester(t, DigestIfCanonicalUnknown, []testCase{
+ {
+ inputDigest: digest.Digest("sha256:uninspected-value"),
+ computesDigest: false,
+ expectedDigest: digest.Digest("sha256:uninspected-value"),
+ },
+ {
+ inputDigest: digest.Digest("unknown-algorithm:uninspected-value"),
+ computesDigest: true,
+ expectedDigest: digest.Canonical.FromBytes(testData),
+ },
+ {
+ inputDigest: "",
+ computesDigest: true,
+ expectedDigest: digest.Canonical.FromBytes(testData),
+ },
+ })
+}
diff --git a/internal/rootless/rootless.go b/internal/rootless/rootless.go
new file mode 100644
index 0000000..80623bf
--- /dev/null
+++ b/internal/rootless/rootless.go
@@ -0,0 +1,25 @@
+package rootless
+
+import (
+ "os"
+ "strconv"
+)
+
+// GetRootlessEUID returns the UID of the current user (in the parent userNS, if any)
+//
+// Podman and similar software, in “rootless” configuration, when run as a non-root
+// user, very early switches to a user namespace, where Geteuid() == 0 (but does not
+// switch to a limited mount namespace); so, code relying on Geteuid() would use
+// system-wide paths in e.g. /var, when the user is actually not privileged to write to
+// them, and expects state to be stored in the home directory.
+//
+// If Podman is setting up such a user namespace, it records the original UID in an
+// environment variable, allowing us to make choices based on the actual user’s identity.
+func GetRootlessEUID() int {
+ euidEnv := os.Getenv("_CONTAINERS_ROOTLESS_UID")
+ if euidEnv != "" {
+ euid, _ := strconv.Atoi(euidEnv)
+ return euid
+ }
+ return os.Geteuid()
+}
diff --git a/internal/set/set.go b/internal/set/set.go
new file mode 100644
index 0000000..acf3034
--- /dev/null
+++ b/internal/set/set.go
@@ -0,0 +1,52 @@
+package set
+
+import "golang.org/x/exp/maps"
+
+// FIXME:
+// - Docstrings
+// - This should be in a public library somewhere
+
+type Set[E comparable] struct {
+ m map[E]struct{}
+}
+
+func New[E comparable]() *Set[E] {
+ return &Set[E]{
+ m: map[E]struct{}{},
+ }
+}
+
+func NewWithValues[E comparable](values ...E) *Set[E] {
+ s := New[E]()
+ for _, v := range values {
+ s.Add(v)
+ }
+ return s
+}
+
+func (s *Set[E]) Add(v E) {
+ s.m[v] = struct{}{} // Possibly writing the same struct{}{} presence marker again.
+}
+
+func (s *Set[E]) AddSlice(slice []E) {
+ for _, v := range slice {
+ s.Add(v)
+ }
+}
+
+func (s *Set[E]) Delete(v E) {
+ delete(s.m, v)
+}
+
+func (s *Set[E]) Contains(v E) bool {
+ _, ok := s.m[v]
+ return ok
+}
+
+func (s *Set[E]) Empty() bool {
+ return len(s.m) == 0
+}
+
+func (s *Set[E]) Values() []E {
+ return maps.Keys(s.m)
+}
diff --git a/internal/set/set_test.go b/internal/set/set_test.go
new file mode 100644
index 0000000..3e704a9
--- /dev/null
+++ b/internal/set/set_test.go
@@ -0,0 +1,77 @@
+package set
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNew(t *testing.T) {
+ s := New[int]()
+ assert.True(t, s.Empty())
+}
+
+func TestNewWithValues(t *testing.T) {
+ s := NewWithValues(1, 3)
+ assert.True(t, s.Contains(1))
+ assert.False(t, s.Contains(2))
+ assert.True(t, s.Contains(3))
+}
+
+func TestAdd(t *testing.T) {
+ s := NewWithValues(1)
+ assert.False(t, s.Contains(2))
+ s.Add(2)
+ assert.True(t, s.Contains(2))
+ s.Add(2) // Adding an already-present element
+ assert.True(t, s.Contains(2))
+ // should not contain duplicate value of `2`
+ assert.ElementsMatch(t, []int{1, 2}, s.Values())
+ // Unrelated elements are unaffected
+ assert.True(t, s.Contains(1))
+ assert.False(t, s.Contains(3))
+}
+
+func TestAddSlice(t *testing.T) {
+ s := NewWithValues(1)
+ s.Add(2)
+ s.AddSlice([]int{3, 4})
+ assert.ElementsMatch(t, []int{1, 2, 3, 4}, s.Values())
+}
+
+func TestDelete(t *testing.T) {
+ s := NewWithValues(1, 2)
+ assert.True(t, s.Contains(2))
+ s.Delete(2)
+ assert.False(t, s.Contains(2))
+ s.Delete(2) // Deleting a missing element
+ assert.False(t, s.Contains(2))
+ // Unrelated elements are unaffected
+ assert.True(t, s.Contains(1))
+}
+
+func TestContains(t *testing.T) {
+ s := NewWithValues(1, 2)
+ assert.True(t, s.Contains(1))
+ assert.True(t, s.Contains(2))
+ assert.False(t, s.Contains(3))
+}
+
+func TestEmpty(t *testing.T) {
+ s := New[int]()
+ assert.True(t, s.Empty())
+ s.Add(1)
+ assert.False(t, s.Empty())
+ s.Delete(1)
+ assert.True(t, s.Empty())
+}
+
+func TestValues(t *testing.T) {
+ s := New[int]()
+ assert.Empty(t, s.Values())
+ s.Add(1)
+ s.Add(2)
+ // ignore duplicate
+ s.Add(2)
+ assert.ElementsMatch(t, []int{1, 2}, s.Values())
+}
diff --git a/internal/signature/signature.go b/internal/signature/signature.go
new file mode 100644
index 0000000..6f95115
--- /dev/null
+++ b/internal/signature/signature.go
@@ -0,0 +1,102 @@
+package signature
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+)
+
+// FIXME FIXME: MIME type? Int? String?
+// An interface with a name, parse methods?
+type FormatID string
+
+const (
+ SimpleSigningFormat FormatID = "simple-signing"
+ SigstoreFormat FormatID = "sigstore-json"
+ // Update also UnsupportedFormatError below
+)
+
+// Signature is an image signature of some kind.
+type Signature interface {
+ FormatID() FormatID
+ // blobChunk returns a representation of signature as a []byte, suitable for long-term storage.
+ // Almost everyone should use signature.Blob() instead.
+ blobChunk() ([]byte, error)
+}
+
+// Blob returns a representation of sig as a []byte, suitable for long-term storage.
+func Blob(sig Signature) ([]byte, error) {
+ chunk, err := sig.blobChunk()
+ if err != nil {
+ return nil, err
+ }
+
+ format := sig.FormatID()
+ switch format {
+ case SimpleSigningFormat:
+ // For compatibility with old dir formats:
+ return chunk, nil
+ default:
+ res := []byte{0} // Start with a zero byte to clearly mark this is a binary format, and disambiguate from random text.
+ res = append(res, []byte(format)...)
+ res = append(res, '\n')
+ res = append(res, chunk...)
+ return res, nil
+ }
+}
+
+// FromBlob returns a signature from parsing a blob created by signature.Blob.
+func FromBlob(blob []byte) (Signature, error) {
+ if len(blob) == 0 {
+ return nil, errors.New("empty signature blob")
+ }
+ // Historically we’ve just been using GPG with no identification; try to auto-detect that.
+ switch blob[0] {
+ // OpenPGP "compressed data" wrapping the message
+ case 0xA0, 0xA1, 0xA2, 0xA3, // bit 7 = 1; bit 6 = 0 (old packet format); bits 5…2 = 8 (tag: compressed data packet); bits 1…0 = length-type (any)
+ 0xC8, // bit 7 = 1; bit 6 = 1 (new packet format); bits 5…0 = 8 (tag: compressed data packet)
+ // OpenPGP “one-pass signature” starting a signature
+ 0x90, 0x91, 0x92, 0x3d, // bit 7 = 1; bit 6 = 0 (old packet format); bits 5…2 = 4 (tag: one-pass signature packet); bits 1…0 = length-type (any)
+ 0xC4, // bit 7 = 1; bit 6 = 1 (new packet format); bits 5…0 = 4 (tag: one-pass signature packet)
+ // OpenPGP signature packet signing the following data
+ 0x88, 0x89, 0x8A, 0x8B, // bit 7 = 1; bit 6 = 0 (old packet format); bits 5…2 = 2 (tag: signature packet); bits 1…0 = length-type (any)
+ 0xC2: // bit 7 = 1; bit 6 = 1 (new packet format); bits 5…0 = 2 (tag: signature packet)
+ return SimpleSigningFromBlob(blob), nil
+
+ // The newer format: binary 0, format name, newline, data
+ case 0x00:
+ blob = blob[1:]
+ formatBytes, blobChunk, foundNewline := bytes.Cut(blob, []byte{'\n'})
+ if !foundNewline {
+ return nil, fmt.Errorf("invalid signature format, missing newline")
+ }
+ for _, b := range formatBytes {
+ if b < 32 || b >= 0x7F {
+ return nil, fmt.Errorf("invalid signature format, non-ASCII byte %#x", b)
+ }
+ }
+ switch {
+ case bytes.Equal(formatBytes, []byte(SimpleSigningFormat)):
+ return SimpleSigningFromBlob(blobChunk), nil
+ case bytes.Equal(formatBytes, []byte(SigstoreFormat)):
+ return sigstoreFromBlobChunk(blobChunk)
+ default:
+ return nil, fmt.Errorf("unrecognized signature format %q", string(formatBytes))
+ }
+
+ default:
+ return nil, fmt.Errorf("unrecognized signature format, starting with binary %#x", blob[0])
+ }
+
+}
+
+// UnsupportedFormatError returns an error complaining about sig having an unsupported format.
+func UnsupportedFormatError(sig Signature) error {
+ formatID := sig.FormatID()
+ switch formatID {
+ case SimpleSigningFormat, SigstoreFormat:
+ return fmt.Errorf("unsupported signature format %s", string(formatID))
+ default:
+ return fmt.Errorf("unsupported, and unrecognized, signature format %q", string(formatID))
+ }
+}
diff --git a/internal/signature/signature_test.go b/internal/signature/signature_test.go
new file mode 100644
index 0000000..924c32d
--- /dev/null
+++ b/internal/signature/signature_test.go
@@ -0,0 +1,94 @@
+package signature
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBlobSimpleSigning(t *testing.T) {
+ simpleSigData, err := os.ReadFile("testdata/simple.signature")
+ require.NoError(t, err)
+ simpleSig := SimpleSigningFromBlob(simpleSigData)
+
+ simpleBlob, err := Blob(simpleSig)
+ require.NoError(t, err)
+ assert.Equal(t, simpleSigData, simpleBlob)
+
+ fromBlob, err := FromBlob(simpleBlob)
+ require.NoError(t, err)
+ fromBlobSimple, ok := fromBlob.(SimpleSigning)
+ require.True(t, ok)
+ assert.Equal(t, simpleSigData, fromBlobSimple.UntrustedSignature())
+
+ // Using the newer format is accepted as well.
+ fromBlob, err = FromBlob(append([]byte("\x00simple-signing\n"), simpleSigData...))
+ require.NoError(t, err)
+ fromBlobSimple, ok = fromBlob.(SimpleSigning)
+ require.True(t, ok)
+ assert.Equal(t, simpleSigData, fromBlobSimple.UntrustedSignature())
+
+}
+
+func TestBlobSigstore(t *testing.T) {
+ sigstoreSig := SigstoreFromComponents("mime-type", []byte("payload"),
+ map[string]string{"a": "b", "c": "d"})
+
+ sigstoreBlob, err := Blob(sigstoreSig)
+ require.NoError(t, err)
+ assert.True(t, bytes.HasPrefix(sigstoreBlob, []byte("\x00sigstore-json\n{")))
+
+ fromBlob, err := FromBlob(sigstoreBlob)
+ require.NoError(t, err)
+ fromBlobSigstore, ok := fromBlob.(Sigstore)
+ require.True(t, ok)
+ assert.Equal(t, sigstoreSig.UntrustedMIMEType(), fromBlobSigstore.UntrustedMIMEType())
+ assert.Equal(t, sigstoreSig.UntrustedPayload(), fromBlobSigstore.UntrustedPayload())
+ assert.Equal(t, sigstoreSig.UntrustedAnnotations(), fromBlobSigstore.UntrustedAnnotations())
+}
+
+func TestFromBlobInvalid(t *testing.T) {
+ // Round-tripping valid data has been tested in TestBlobSimpleSigning and TestBlobSigstore above.
+ for _, c := range []string{
+ "", // Empty
+ "\xFFsimple-signing\nhello", // Invalid first byte
+ "\x00simple-signing", // No newline
+ "\x00format\xFFname\ndata", // Non-ASCII format value
+ "\x00unknown-format\ndata", // Unknown format
+ } {
+ _, err := FromBlob([]byte(c))
+ assert.Error(t, err, fmt.Sprintf("%#v", c))
+ }
+}
+
+// mockFormatSignature returns a specified format
+type mockFormatSignature struct {
+ fmt FormatID
+}
+
+func (ms mockFormatSignature) FormatID() FormatID {
+ return ms.fmt
+}
+
+func (ms mockFormatSignature) blobChunk() ([]byte, error) {
+ panic("Unexpected call to a mock function")
+}
+
+func TestUnsuportedFormatError(t *testing.T) {
+ // Warning: The exact text returned by the function is not an API commitment.
+ for _, c := range []struct {
+ input Signature
+ expected string
+ }{
+ {SimpleSigningFromBlob(nil), "unsupported signature format simple-signing"},
+ {SigstoreFromComponents("mime-type", nil, nil), "unsupported signature format sigstore-json"},
+ {mockFormatSignature{FormatID("invalid")}, `unsupported, and unrecognized, signature format "invalid"`},
+ } {
+ res := UnsupportedFormatError(c.input)
+ assert.Equal(t, c.expected, res.Error(), string(c.input.FormatID()))
+ }
+}
diff --git a/internal/signature/sigstore.go b/internal/signature/sigstore.go
new file mode 100644
index 0000000..b8a9b36
--- /dev/null
+++ b/internal/signature/sigstore.go
@@ -0,0 +1,87 @@
+package signature
+
+import (
+ "encoding/json"
+
+ "golang.org/x/exp/maps"
+ "golang.org/x/exp/slices"
+)
+
+const (
+ // from sigstore/cosign/pkg/types.SimpleSigningMediaType
+ SigstoreSignatureMIMEType = "application/vnd.dev.cosign.simplesigning.v1+json"
+ // from sigstore/cosign/pkg/oci/static.SignatureAnnotationKey
+ SigstoreSignatureAnnotationKey = "dev.cosignproject.cosign/signature"
+ // from sigstore/cosign/pkg/oci/static.BundleAnnotationKey
+ SigstoreSETAnnotationKey = "dev.sigstore.cosign/bundle"
+ // from sigstore/cosign/pkg/oci/static.CertificateAnnotationKey
+ SigstoreCertificateAnnotationKey = "dev.sigstore.cosign/certificate"
+ // from sigstore/cosign/pkg/oci/static.ChainAnnotationKey
+ SigstoreIntermediateCertificateChainAnnotationKey = "dev.sigstore.cosign/chain"
+)
+
+// Sigstore is a github.com/cosign/cosign signature.
+// For the persistent-storage format used for blobChunk(), we want
+// a degree of forward compatibility against unexpected field changes
+// (as has happened before), which is why this data type
+// contains just a payload + annotations (including annotations
+// that we don’t recognize or support), instead of individual fields
+// for the known annotations.
+type Sigstore struct {
+ untrustedMIMEType string
+ untrustedPayload []byte
+ untrustedAnnotations map[string]string
+}
+
+// sigstoreJSONRepresentation needs the files to be public, which we don’t want for
+// the main Sigstore type.
+type sigstoreJSONRepresentation struct {
+ UntrustedMIMEType string `json:"mimeType"`
+ UntrustedPayload []byte `json:"payload"`
+ UntrustedAnnotations map[string]string `json:"annotations"`
+}
+
+// SigstoreFromComponents returns a Sigstore object from its components.
+func SigstoreFromComponents(untrustedMimeType string, untrustedPayload []byte, untrustedAnnotations map[string]string) Sigstore {
+ return Sigstore{
+ untrustedMIMEType: untrustedMimeType,
+ untrustedPayload: slices.Clone(untrustedPayload),
+ untrustedAnnotations: maps.Clone(untrustedAnnotations),
+ }
+}
+
+// sigstoreFromBlobChunk converts a Sigstore signature, as returned by Sigstore.blobChunk, into a Sigstore object.
+func sigstoreFromBlobChunk(blobChunk []byte) (Sigstore, error) {
+ var v sigstoreJSONRepresentation
+ if err := json.Unmarshal(blobChunk, &v); err != nil {
+ return Sigstore{}, err
+ }
+ return SigstoreFromComponents(v.UntrustedMIMEType,
+ v.UntrustedPayload,
+ v.UntrustedAnnotations), nil
+}
+
+func (s Sigstore) FormatID() FormatID {
+ return SigstoreFormat
+}
+
+// blobChunk returns a representation of signature as a []byte, suitable for long-term storage.
+// Almost everyone should use signature.Blob() instead.
+func (s Sigstore) blobChunk() ([]byte, error) {
+ return json.Marshal(sigstoreJSONRepresentation{
+ UntrustedMIMEType: s.UntrustedMIMEType(),
+ UntrustedPayload: s.UntrustedPayload(),
+ UntrustedAnnotations: s.UntrustedAnnotations(),
+ })
+}
+
+func (s Sigstore) UntrustedMIMEType() string {
+ return s.untrustedMIMEType
+}
+func (s Sigstore) UntrustedPayload() []byte {
+ return slices.Clone(s.untrustedPayload)
+}
+
+func (s Sigstore) UntrustedAnnotations() map[string]string {
+ return maps.Clone(s.untrustedAnnotations)
+}
diff --git a/internal/signature/sigstore_test.go b/internal/signature/sigstore_test.go
new file mode 100644
index 0000000..c1d3c9b
--- /dev/null
+++ b/internal/signature/sigstore_test.go
@@ -0,0 +1,71 @@
+package signature
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSigstoreFromComponents(t *testing.T) {
+ const mimeType = "mime-type"
+ payload := []byte("payload")
+ annotations := map[string]string{"a": "b", "c": "d"}
+
+ sig := SigstoreFromComponents(mimeType, payload, annotations)
+ assert.Equal(t, Sigstore{
+ untrustedMIMEType: mimeType,
+ untrustedPayload: payload,
+ untrustedAnnotations: annotations,
+ }, sig)
+}
+
+func TestSigstoreFromBlobChunk(t *testing.T) {
+ // Success
+ json := []byte(`{"mimeType":"mime-type","payload":"cGF5bG9hZA==", "annotations":{"a":"b","c":"d"}}`)
+ res, err := sigstoreFromBlobChunk(json)
+ require.NoError(t, err)
+ assert.Equal(t, "mime-type", res.UntrustedMIMEType())
+ assert.Equal(t, []byte("payload"), res.UntrustedPayload())
+ assert.Equal(t, map[string]string{"a": "b", "c": "d"}, res.UntrustedAnnotations())
+
+ // Invalid JSON
+ _, err = sigstoreFromBlobChunk([]byte("&"))
+ assert.Error(t, err)
+}
+
+func TestSigstoreFormatID(t *testing.T) {
+ sig := SigstoreFromComponents("mime-type", []byte("payload"),
+ map[string]string{"a": "b", "c": "d"})
+ assert.Equal(t, SigstoreFormat, sig.FormatID())
+}
+
+func TestSigstoreBlobChunk(t *testing.T) {
+ sig := SigstoreFromComponents("mime-type", []byte("payload"),
+ map[string]string{"a": "b", "c": "d"})
+ res, err := sig.blobChunk()
+ require.NoError(t, err)
+
+ expectedJSON := []byte(`{"mimeType":"mime-type","payload":"cGF5bG9hZA==", "annotations":{"a":"b","c":"d"}}`)
+ // Don’t directly compare the JSON representation so that we don’t test for formatting differences, just verify that it contains exactly the expected data.
+ var raw, expectedRaw map[string]any
+ err = json.Unmarshal(res, &raw)
+ require.NoError(t, err)
+ err = json.Unmarshal(expectedJSON, &expectedRaw)
+ require.NoError(t, err)
+ assert.Equal(t, expectedRaw, raw)
+}
+
+func TestSigstoreUntrustedPayload(t *testing.T) {
+ var payload = []byte("payload")
+ sig := SigstoreFromComponents("mime-type", payload,
+ map[string]string{"a": "b", "c": "d"})
+ assert.Equal(t, payload, sig.UntrustedPayload())
+}
+
+func TestSigstoreUntrustedAnnotations(t *testing.T) {
+ annotations := map[string]string{"a": "b", "c": "d"}
+ sig := SigstoreFromComponents("mime-type", []byte("payload"), annotations)
+ assert.Equal(t, annotations, sig.UntrustedAnnotations())
+}
diff --git a/internal/signature/simple.go b/internal/signature/simple.go
new file mode 100644
index 0000000..c093704
--- /dev/null
+++ b/internal/signature/simple.go
@@ -0,0 +1,29 @@
+package signature
+
+import "golang.org/x/exp/slices"
+
+// SimpleSigning is a “simple signing” signature.
+type SimpleSigning struct {
+ untrustedSignature []byte
+}
+
+// SimpleSigningFromBlob converts a “simple signing” signature into a SimpleSigning object.
+func SimpleSigningFromBlob(blobChunk []byte) SimpleSigning {
+ return SimpleSigning{
+ untrustedSignature: slices.Clone(blobChunk),
+ }
+}
+
+func (s SimpleSigning) FormatID() FormatID {
+ return SimpleSigningFormat
+}
+
+// blobChunk returns a representation of signature as a []byte, suitable for long-term storage.
+// Almost everyone should use signature.Blob() instead.
+func (s SimpleSigning) blobChunk() ([]byte, error) {
+ return slices.Clone(s.untrustedSignature), nil
+}
+
+func (s SimpleSigning) UntrustedSignature() []byte {
+ return slices.Clone(s.untrustedSignature)
+}
diff --git a/internal/signature/simple_test.go b/internal/signature/simple_test.go
new file mode 100644
index 0000000..76537e2
--- /dev/null
+++ b/internal/signature/simple_test.go
@@ -0,0 +1,36 @@
+package signature
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSimpleSigningFromBlob(t *testing.T) {
+ var data = []byte("some contents")
+
+ sig := SimpleSigningFromBlob(data)
+ assert.Equal(t, SimpleSigning{untrustedSignature: data}, sig)
+}
+
+func TestSimpleSigningFormatID(t *testing.T) {
+ sig := SimpleSigningFromBlob([]byte("some contents"))
+ assert.Equal(t, SimpleSigningFormat, sig.FormatID())
+}
+
+func TestSimpleSigningBlobChunk(t *testing.T) {
+ var data = []byte("some contents")
+
+ sig := SimpleSigningFromBlob(data)
+ chunk, err := sig.blobChunk()
+ require.NoError(t, err)
+ assert.Equal(t, data, chunk)
+}
+
+func TestSimpleSigningUntrustedSignature(t *testing.T) {
+ var data = []byte("some contents")
+
+ sig := SimpleSigningFromBlob(data)
+ assert.Equal(t, data, sig.UntrustedSignature())
+}
diff --git a/internal/signature/testdata/simple.signature b/internal/signature/testdata/simple.signature
new file mode 120000
index 0000000..dae8bd5
--- /dev/null
+++ b/internal/signature/testdata/simple.signature
@@ -0,0 +1 @@
+../../../signature/fixtures/image.signature \ No newline at end of file
diff --git a/internal/signer/signer.go b/internal/signer/signer.go
new file mode 100644
index 0000000..5720254
--- /dev/null
+++ b/internal/signer/signer.go
@@ -0,0 +1,47 @@
+package signer
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/signature"
+)
+
+// Signer is an object, possibly carrying state, that can be used by copy.Image to sign one or more container images.
+// This type is visible to external callers, so it has no public fields or methods apart from Close().
+//
+// The owner of a Signer must call Close() when done.
+type Signer struct {
+ implementation SignerImplementation
+}
+
+// NewSigner creates a public Signer from a SignerImplementation
+func NewSigner(impl SignerImplementation) *Signer {
+ return &Signer{implementation: impl}
+}
+
+func (s *Signer) Close() error {
+ return s.implementation.Close()
+}
+
+// ProgressMessage returns a human-readable sentence that makes sense to write before starting to create a single signature.
+// Alternatively, should SignImageManifest be provided a logging writer of some kind?
+func ProgressMessage(signer *Signer) string {
+ return signer.implementation.ProgressMessage()
+}
+
+// SignImageManifest invokes a SignerImplementation.
+// This is a function, not a method, so that it can only be called by code that is allowed to import this internal subpackage.
+func SignImageManifest(ctx context.Context, signer *Signer, manifest []byte, dockerReference reference.Named) (signature.Signature, error) {
+ return signer.implementation.SignImageManifest(ctx, manifest, dockerReference)
+}
+
+// SignerImplementation is an object, possibly carrying state, that can be used by copy.Image to sign one or more container images.
+// This interface is distinct from Signer so that implementations can be created outside of this package.
+type SignerImplementation interface {
+ // ProgressMessage returns a human-readable sentence that makes sense to write before starting to create a single signature.
+ ProgressMessage() string
+ // SignImageManifest creates a new signature for manifest m as dockerReference.
+ SignImageManifest(ctx context.Context, m []byte, dockerReference reference.Named) (signature.Signature, error)
+ Close() error
+}
diff --git a/internal/signer/signer_test.go b/internal/signer/signer_test.go
new file mode 100644
index 0000000..eaa18cf
--- /dev/null
+++ b/internal/signer/signer_test.go
@@ -0,0 +1,87 @@
+package signer
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// mockSignerImplementation is a SignerImplementation used only for tests.
+type mockSignerImplementation struct {
+ progressMessage func() string
+ signImageManifest func(ctx context.Context, m []byte, dockerReference reference.Named) (signature.Signature, error)
+ close func() error
+}
+
+func (ms *mockSignerImplementation) Close() error {
+ return ms.close()
+}
+
+func (ms *mockSignerImplementation) ProgressMessage() string {
+ return ms.progressMessage()
+}
+
+func (ms *mockSignerImplementation) SignImageManifest(ctx context.Context, m []byte, dockerReference reference.Named) (signature.Signature, error) {
+ return ms.signImageManifest(ctx, m, dockerReference)
+}
+
+func TestNewSigner(t *testing.T) {
+ closeError := errors.New("unique error")
+
+ si := mockSignerImplementation{
+ // Other functions are nil, so this ensures they are not called.
+ close: func() error { return closeError },
+ }
+ s := NewSigner(&si)
+ // Verify SignerImplementation methods are not visible even to determined callers
+ _, visible := any(s).(SignerImplementation)
+ assert.False(t, visible)
+ err := s.Close()
+ assert.Equal(t, closeError, err)
+}
+
+func TestProgressMessage(t *testing.T) {
+ si := mockSignerImplementation{
+ // Other functions are nil, so this ensures they are not called.
+ close: func() error { return nil },
+ }
+ s := NewSigner(&si)
+ defer s.Close()
+
+ const testMessage = "some unique string"
+ si.progressMessage = func() string {
+ return testMessage
+ }
+ message := ProgressMessage(s)
+ assert.Equal(t, testMessage, message)
+}
+
+func TestSignImageManifest(t *testing.T) {
+ si := mockSignerImplementation{
+ // Other functions are nil, so this ensures they are not called.
+ close: func() error { return nil },
+ }
+ s := NewSigner(&si)
+ defer s.Close()
+
+ testManifest := []byte("some manifest")
+ testDR, err := reference.ParseNormalizedNamed("busybox")
+ require.NoError(t, err)
+ testContext := context.WithValue(context.Background(), struct{}{}, "make this context unique")
+ testSig := signature.SigstoreFromComponents(signature.SigstoreSignatureMIMEType, []byte("payload"), nil)
+ testErr := errors.New("some unique error")
+ si.signImageManifest = func(ctx context.Context, m []byte, dockerReference reference.Named) (signature.Signature, error) {
+ assert.Equal(t, testContext, ctx)
+ assert.Equal(t, testManifest, m)
+ assert.Equal(t, testDR, dockerReference)
+ return testSig, testErr
+ }
+ sig, err := SignImageManifest(testContext, s, testManifest, testDR)
+ assert.Equal(t, testSig, sig)
+ assert.Equal(t, testErr, err)
+}
diff --git a/internal/streamdigest/fixtures/Hello.uncompressed b/internal/streamdigest/fixtures/Hello.uncompressed
new file mode 100644
index 0000000..5ab2f8a
--- /dev/null
+++ b/internal/streamdigest/fixtures/Hello.uncompressed
@@ -0,0 +1 @@
+Hello \ No newline at end of file
diff --git a/internal/streamdigest/stream_digest.go b/internal/streamdigest/stream_digest.go
new file mode 100644
index 0000000..d5a5436
--- /dev/null
+++ b/internal/streamdigest/stream_digest.go
@@ -0,0 +1,40 @@
+package streamdigest
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/containers/image/v5/internal/putblobdigest"
+ "github.com/containers/image/v5/internal/tmpdir"
+ "github.com/containers/image/v5/types"
+)
+
+// ComputeBlobInfo streams a blob to a temporary file and populates Digest and Size in inputInfo.
+// The temporary file is returned as an io.Reader along with a cleanup function.
+// It is the caller's responsibility to call the cleanup function, which closes and removes the temporary file.
+// If an error occurs, inputInfo is not modified.
+func ComputeBlobInfo(sys *types.SystemContext, stream io.Reader, inputInfo *types.BlobInfo) (io.Reader, func(), error) {
+ diskBlob, err := tmpdir.CreateBigFileTemp(sys, "stream-blob")
+ if err != nil {
+ return nil, nil, fmt.Errorf("creating temporary on-disk layer: %w", err)
+ }
+ cleanup := func() {
+ diskBlob.Close()
+ os.Remove(diskBlob.Name())
+ }
+ digester, stream := putblobdigest.DigestIfCanonicalUnknown(stream, *inputInfo)
+ written, err := io.Copy(diskBlob, stream)
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("writing to temporary on-disk layer: %w", err)
+ }
+ _, err = diskBlob.Seek(0, io.SeekStart)
+ if err != nil {
+ cleanup()
+ return nil, nil, fmt.Errorf("rewinding temporary on-disk layer: %w", err)
+ }
+ inputInfo.Digest = digester.Digest()
+ inputInfo.Size = written
+ return diskBlob, cleanup, nil
+}
diff --git a/internal/streamdigest/stream_digest_test.go b/internal/streamdigest/stream_digest_test.go
new file mode 100644
index 0000000..b0a1327
--- /dev/null
+++ b/internal/streamdigest/stream_digest_test.go
@@ -0,0 +1,36 @@
+package streamdigest
+
+import (
+ "io"
+ "os"
+ "testing"
+
+ "github.com/containers/image/v5/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestComputeBlobInfo(t *testing.T) {
+ inputInfo := types.BlobInfo{Digest: "", Size: -1}
+ fixtureFname := "fixtures/Hello.uncompressed"
+ fixtureInfo := types.BlobInfo{Digest: "sha256:185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969", Size: 5}
+ fixtureBytes := []byte("Hello")
+
+ // open fixture
+ stream, err := os.Open(fixtureFname)
+ require.NoError(t, err, fixtureFname)
+ defer stream.Close()
+
+ // fill in Digest and Size for inputInfo
+ streamCopy, cleanup, err := ComputeBlobInfo(nil, stream, &inputInfo)
+ require.NoError(t, err)
+ defer cleanup()
+
+ // ensure inputInfo has been filled in with Digest and Size of fixture
+ assert.Equal(t, inputInfo, fixtureInfo)
+
+ // ensure streamCopy is the same as fixture
+ b, err := io.ReadAll(streamCopy)
+ require.NoError(t, err)
+ assert.Equal(t, b, fixtureBytes)
+}
diff --git a/internal/testing/explicitfilepath-tmpdir/tmpdir.go b/internal/testing/explicitfilepath-tmpdir/tmpdir.go
new file mode 100644
index 0000000..a47ada4
--- /dev/null
+++ b/internal/testing/explicitfilepath-tmpdir/tmpdir.go
@@ -0,0 +1,29 @@
+// Package tmpdir is a TESTING-ONLY utility.
+//
+// Some tests directly or indirectly exercising the directory/explicitfilepath
+// subpackage expect the path returned by os.MkdirTemp to be canonical in the
+// directory/explicitfilepath sense (absolute, no symlinks, cleaned up).
+//
+// os.MkdirTemp uses $TMPDIR by default, and on macOS, $TMPDIR is by
+// default set to /var/folders/…, with /var a symlink to /private/var ,
+// which does not match our expectations. So, tests which want to use
+// os.MkdirTemp that way, can
+// import _ "github.com/containers/image/internal/testing/explicitfilepath-tmpdir"
+// to ensure that $TMPDIR is canonical and usable as a base for testing
+// path canonicalization in its subdirectories.
+//
+// NEVER use this in non-testing subpackages!
+package tmpdir
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func init() {
+ tmpDir := os.TempDir()
+ explicitTmpDir, err := filepath.EvalSymlinks(tmpDir)
+ if err == nil {
+ os.Setenv("TMPDIR", explicitTmpDir)
+ }
+}
diff --git a/internal/testing/gpgagent/gpg_agent.go b/internal/testing/gpgagent/gpg_agent.go
new file mode 100644
index 0000000..148b455
--- /dev/null
+++ b/internal/testing/gpgagent/gpg_agent.go
@@ -0,0 +1,16 @@
+package gpgagent
+
+import (
+ "os"
+ "os/exec"
+
+ "golang.org/x/exp/slices"
+)
+
+// Kill the running gpg-agent to drop unlocked keys.
+// This is useful to ensure tests don’t leave processes around (in TestMain), or for testing handling of invalid passphrases.
+func KillGPGAgent(gpgHomeDir string) error {
+ cmd := exec.Command("gpgconf", "--kill", "gpg-agent")
+ cmd.Env = append(slices.Clone(os.Environ()), "GNUPGHOME="+gpgHomeDir)
+ return cmd.Run()
+}
diff --git a/internal/testing/mocks/image_reference.go b/internal/testing/mocks/image_reference.go
new file mode 100644
index 0000000..bf01d00
--- /dev/null
+++ b/internal/testing/mocks/image_reference.go
@@ -0,0 +1,56 @@
+package mocks
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/docker/reference"
+ "github.com/containers/image/v5/types"
+)
+
+// ForbiddenImageReference is used when we don’t expect the ImageReference to be used in our tests.
+type ForbiddenImageReference struct{}
+
+// Transport is a mock that panics.
+func (ref ForbiddenImageReference) Transport() types.ImageTransport {
+ panic("unexpected call to a mock function")
+}
+
+// StringWithinTransport is a mock that panics.
+func (ref ForbiddenImageReference) StringWithinTransport() string {
+ panic("unexpected call to a mock function")
+}
+
+// DockerReference is a mock that panics.
+func (ref ForbiddenImageReference) DockerReference() reference.Named {
+ panic("unexpected call to a mock function")
+}
+
+// PolicyConfigurationIdentity is a mock that panics.
+func (ref ForbiddenImageReference) PolicyConfigurationIdentity() string {
+ panic("unexpected call to a mock function")
+}
+
+// PolicyConfigurationNamespaces is a mock that panics.
+func (ref ForbiddenImageReference) PolicyConfigurationNamespaces() []string {
+ panic("unexpected call to a mock function")
+}
+
+// NewImage is a mock that panics.
+func (ref ForbiddenImageReference) NewImage(ctx context.Context, sys *types.SystemContext) (types.ImageCloser, error) {
+ panic("unexpected call to a mock function")
+}
+
+// NewImageSource is a mock that panics.
+func (ref ForbiddenImageReference) NewImageSource(ctx context.Context, sys *types.SystemContext) (types.ImageSource, error) {
+ panic("unexpected call to a mock function")
+}
+
+// NewImageDestination is a mock that panics.
+func (ref ForbiddenImageReference) NewImageDestination(ctx context.Context, sys *types.SystemContext) (types.ImageDestination, error) {
+ panic("unexpected call to a mock function")
+}
+
+// DeleteImage is a mock that panics.
+func (ref ForbiddenImageReference) DeleteImage(ctx context.Context, sys *types.SystemContext) error {
+ panic("unexpected call to a mock function")
+}
diff --git a/internal/testing/mocks/image_source.go b/internal/testing/mocks/image_source.go
new file mode 100644
index 0000000..754f5f1
--- /dev/null
+++ b/internal/testing/mocks/image_source.go
@@ -0,0 +1,47 @@
+package mocks
+
+import (
+ "context"
+ "io"
+
+ "github.com/containers/image/v5/types"
+ "github.com/opencontainers/go-digest"
+)
+
+// ForbiddenImageSource is used when we don't expect the ImageSource to be used in our tests.
+type ForbiddenImageSource struct{}
+
+// Reference is a mock that panics.
+func (f ForbiddenImageSource) Reference() types.ImageReference {
+ panic("Unexpected call to a mock function")
+}
+
+// Close is a mock that panics.
+func (f ForbiddenImageSource) Close() error {
+ panic("Unexpected call to a mock function")
+}
+
+// GetManifest is a mock that panics.
+func (f ForbiddenImageSource) GetManifest(context.Context, *digest.Digest) ([]byte, string, error) {
+ panic("Unexpected call to a mock function")
+}
+
+// GetBlob is a mock that panics.
+func (f ForbiddenImageSource) GetBlob(context.Context, types.BlobInfo, types.BlobInfoCache) (io.ReadCloser, int64, error) {
+ panic("Unexpected call to a mock function")
+}
+
+// HasThreadSafeGetBlob is a mock that panics.
+func (f ForbiddenImageSource) HasThreadSafeGetBlob() bool {
+ panic("Unexpected call to a mock function")
+}
+
+// GetSignatures is a mock that panics.
+func (f ForbiddenImageSource) GetSignatures(context.Context, *digest.Digest) ([][]byte, error) {
+ panic("Unexpected call to a mock function")
+}
+
+// LayerInfosForCopy is a mock that panics.
+func (f ForbiddenImageSource) LayerInfosForCopy(context.Context, *digest.Digest) ([]types.BlobInfo, error) {
+ panic("Unexpected call to a mock function")
+}
diff --git a/internal/testing/mocks/image_transport.go b/internal/testing/mocks/image_transport.go
new file mode 100644
index 0000000..c551949
--- /dev/null
+++ b/internal/testing/mocks/image_transport.go
@@ -0,0 +1,24 @@
+package mocks
+
+import "github.com/containers/image/v5/types"
+
+// NameImageTransport is a mock of types.ImageTransport which returns itself in Name.
+type NameImageTransport string
+
+// Name returns the name of the transport, which must be unique among other transports.
+func (name NameImageTransport) Name() string {
+ return string(name)
+}
+
+// ParseReference converts a string, which should not start with the ImageTransport.Name prefix, into an ImageReference.
+func (name NameImageTransport) ParseReference(reference string) (types.ImageReference, error) {
+ panic("unexpected call to a mock function")
+}
+
+// ValidatePolicyConfigurationScope checks that scope is a valid name for a signature.PolicyTransportScopes keys
+// (i.e. a valid PolicyConfigurationIdentity() or PolicyConfigurationNamespaces() return value).
+// It is acceptable to allow an invalid value which will never be matched, it can "only" cause user confusion.
+// scope passed to this function will not be "", that value is always allowed.
+func (name NameImageTransport) ValidatePolicyConfigurationScope(scope string) error {
+ panic("unexpected call to a mock function")
+}
diff --git a/internal/testing/mocks/unparsed_image.go b/internal/testing/mocks/unparsed_image.go
new file mode 100644
index 0000000..a2e2f84
--- /dev/null
+++ b/internal/testing/mocks/unparsed_image.go
@@ -0,0 +1,31 @@
+package mocks
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/containers/image/v5/types"
+)
+
+// ForbiddenUnparsedImage is used when we don't expect the UnparsedImage to be used in our tests.
+type ForbiddenUnparsedImage struct{}
+
+// Reference is a mock that panics.
+func (ref ForbiddenUnparsedImage) Reference() types.ImageReference {
+ panic("unexpected call to a mock function")
+}
+
+// Manifest is a mock that panics.
+func (ref ForbiddenUnparsedImage) Manifest(ctx context.Context) ([]byte, string, error) {
+ panic("unexpected call to a mock function")
+}
+
+// Signatures is a mock that panics.
+func (ref ForbiddenUnparsedImage) Signatures(context.Context) ([][]byte, error) {
+ panic("unexpected call to a mock function")
+}
+
+// UntrustedSignatures is a mock that panics.
+func (ref ForbiddenUnparsedImage) UntrustedSignatures(ctx context.Context) ([]signature.Signature, error) {
+ panic("unexpected call to a mock function")
+}
diff --git a/internal/tmpdir/tmpdir.go b/internal/tmpdir/tmpdir.go
new file mode 100644
index 0000000..bab73ee
--- /dev/null
+++ b/internal/tmpdir/tmpdir.go
@@ -0,0 +1,44 @@
+package tmpdir
+
+import (
+ "os"
+ "runtime"
+
+ "github.com/containers/image/v5/types"
+)
+
+// unixTempDirForBigFiles is the directory path to store big files on non Windows systems.
+// You can override this at build time with
+// -ldflags '-X github.com/containers/image/v5/internal/tmpdir.unixTempDirForBigFiles=$your_path'
+var unixTempDirForBigFiles = builtinUnixTempDirForBigFiles
+
+// builtinUnixTempDirForBigFiles is the directory path to store big files.
+// Do not use the system default of os.TempDir(), usually /tmp, because with systemd it could be a tmpfs.
+// DO NOT change this, instead see unixTempDirForBigFiles above.
+const builtinUnixTempDirForBigFiles = "/var/tmp"
+
+const prefix = "container_images_"
+
+// TemporaryDirectoryForBigFiles returns a directory for temporary (big) files.
+// On non Windows systems it avoids the use of os.TempDir(), because the default temporary directory usually falls under /tmp
+// which on systemd based systems could be the unsuitable tmpfs filesystem.
+func temporaryDirectoryForBigFiles(sys *types.SystemContext) string {
+ if sys != nil && sys.BigFilesTemporaryDir != "" {
+ return sys.BigFilesTemporaryDir
+ }
+ var temporaryDirectoryForBigFiles string
+ if runtime.GOOS == "windows" {
+ temporaryDirectoryForBigFiles = os.TempDir()
+ } else {
+ temporaryDirectoryForBigFiles = unixTempDirForBigFiles
+ }
+ return temporaryDirectoryForBigFiles
+}
+
+func CreateBigFileTemp(sys *types.SystemContext, name string) (*os.File, error) {
+ return os.CreateTemp(temporaryDirectoryForBigFiles(sys), prefix+name)
+}
+
+func MkDirBigFileTemp(sys *types.SystemContext, name string) (string, error) {
+ return os.MkdirTemp(temporaryDirectoryForBigFiles(sys), prefix+name)
+}
diff --git a/internal/tmpdir/tmpdir_test.go b/internal/tmpdir/tmpdir_test.go
new file mode 100644
index 0000000..c36caf3
--- /dev/null
+++ b/internal/tmpdir/tmpdir_test.go
@@ -0,0 +1,54 @@
+package tmpdir
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/containers/image/v5/types"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCreateBigFileTemp(t *testing.T) {
+ f, err := CreateBigFileTemp(nil, "")
+ assert.NoError(t, err)
+ f.Close()
+ os.Remove(f.Name())
+
+ f, err = CreateBigFileTemp(nil, "foobar")
+ assert.NoError(t, err)
+ f.Close()
+ assert.True(t, strings.Contains(f.Name(), prefix+"foobar"))
+ os.Remove(f.Name())
+
+ var sys types.SystemContext
+ sys.BigFilesTemporaryDir = "/tmp"
+ f, err = CreateBigFileTemp(&sys, "foobar1")
+ assert.NoError(t, err)
+ f.Close()
+ assert.True(t, strings.Contains(f.Name(), "/tmp/"+prefix+"foobar1"))
+ os.Remove(f.Name())
+
+ sys.BigFilesTemporaryDir = "/tmp/bogus"
+ _, err = CreateBigFileTemp(&sys, "foobar1")
+ assert.Error(t, err)
+
+}
+
+func TestMkDirBigFileTemp(t *testing.T) {
+ d, err := MkDirBigFileTemp(nil, "foobar")
+ assert.NoError(t, err)
+ assert.True(t, strings.Contains(d, prefix+"foobar"))
+ os.RemoveAll(d)
+
+ var sys types.SystemContext
+ sys.BigFilesTemporaryDir = "/tmp"
+ d, err = MkDirBigFileTemp(&sys, "foobar1")
+ assert.NoError(t, err)
+ assert.True(t, strings.Contains(d, "/tmp/"+prefix+"foobar1"))
+ os.RemoveAll(d)
+
+ sys.BigFilesTemporaryDir = "/tmp/bogus"
+ _, err = MkDirBigFileTemp(&sys, "foobar1")
+ assert.Error(t, err)
+}
diff --git a/internal/unparsedimage/wrapper.go b/internal/unparsedimage/wrapper.go
new file mode 100644
index 0000000..fe65b1a
--- /dev/null
+++ b/internal/unparsedimage/wrapper.go
@@ -0,0 +1,38 @@
+package unparsedimage
+
+import (
+ "context"
+
+ "github.com/containers/image/v5/internal/private"
+ "github.com/containers/image/v5/internal/signature"
+ "github.com/containers/image/v5/types"
+)
+
+// wrapped provides the private.UnparsedImage operations
+// for an object that only implements types.UnparsedImage
+type wrapped struct {
+ types.UnparsedImage
+}
+
+// FromPublic(unparsed) returns an object that provides the private.UnparsedImage API
+func FromPublic(unparsed types.UnparsedImage) private.UnparsedImage {
+ if unparsed2, ok := unparsed.(private.UnparsedImage); ok {
+ return unparsed2
+ }
+ return &wrapped{
+ UnparsedImage: unparsed,
+ }
+}
+
+// UntrustedSignatures is like ImageSource.GetSignaturesWithFormat, but the result is cached; it is OK to call this however often you need.
+func (w *wrapped) UntrustedSignatures(ctx context.Context) ([]signature.Signature, error) {
+ sigs, err := w.Signatures(ctx)
+ if err != nil {
+ return nil, err
+ }
+ res := []signature.Signature{}
+ for _, sig := range sigs {
+ res = append(res, signature.SimpleSigningFromBlob(sig))
+ }
+ return res, nil
+}
diff --git a/internal/uploadreader/upload_reader.go b/internal/uploadreader/upload_reader.go
new file mode 100644
index 0000000..b95370a
--- /dev/null
+++ b/internal/uploadreader/upload_reader.go
@@ -0,0 +1,61 @@
+package uploadreader
+
+import (
+ "io"
+ "sync"
+)
+
+// UploadReader is a pass-through reader for use in sending non-trivial data using the net/http
+// package (http.NewRequest, http.Post and the like).
+//
+// The net/http package uses a separate goroutine to upload data to a HTTP connection,
+// and it is possible for the server to return a response (typically an error) before consuming
+// the full body of the request. In that case http.Client.Do can return with an error while
+// the body is still being read — regardless of the cancellation, if any, of http.Request.Context().
+//
+// As a result, any data used/updated by the io.Reader() provided as the request body may be
+// used/updated even after http.Client.Do returns, causing races.
+//
+// To fix this, UploadReader provides a synchronized Terminate() method, which can block for
+// a not-completely-negligible time (for a duration of the underlying Read()), but guarantees that
+// after Terminate() returns, the underlying reader is never used any more (unlike calling
+// the cancellation callback of context.WithCancel, which returns before any recipients may have
+// reacted to the cancellation).
+type UploadReader struct {
+ mutex sync.Mutex
+ // The following members can only be used with mutex held
+ reader io.Reader
+ terminationError error // nil if not terminated yet
+}
+
+// NewUploadReader returns an UploadReader for an "underlying" reader.
+func NewUploadReader(underlying io.Reader) *UploadReader {
+ return &UploadReader{
+ reader: underlying,
+ terminationError: nil,
+ }
+}
+
+// Read returns the error set by Terminate, if any, or calls the underlying reader.
+// It is safe to call this from a different goroutine than Terminate.
+func (ur *UploadReader) Read(p []byte) (int, error) {
+ ur.mutex.Lock()
+ defer ur.mutex.Unlock()
+
+ if ur.terminationError != nil {
+ return 0, ur.terminationError
+ }
+ return ur.reader.Read(p)
+}
+
+// Terminate waits for in-progress Read calls, if any, to finish, and ensures that after
+// this function returns, any Read calls will fail with the provided error, and the underlying
+// reader will never be used any more.
+//
+// It is safe to call this from a different goroutine than Read.
+func (ur *UploadReader) Terminate(err error) {
+ ur.mutex.Lock() // May block for some time if ur.reader.Read() is in progress
+ defer ur.mutex.Unlock()
+
+ ur.terminationError = err
+}
diff --git a/internal/uploadreader/upload_reader_test.go b/internal/uploadreader/upload_reader_test.go
new file mode 100644
index 0000000..c91967a
--- /dev/null
+++ b/internal/uploadreader/upload_reader_test.go
@@ -0,0 +1,34 @@
+package uploadreader
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUploadReader(t *testing.T) {
+ // This is a smoke test in a single goroutine, without really testing the locking.
+
+ data := bytes.Repeat([]byte{0x01}, 65535)
+ // No termination
+ ur := NewUploadReader(bytes.NewReader(data))
+ read, err := io.ReadAll(ur)
+ require.NoError(t, err)
+ assert.Equal(t, data, read)
+
+ // Terminated
+ ur = NewUploadReader(bytes.NewReader(data))
+ readLen := len(data) / 2
+ read, err = io.ReadAll(io.LimitReader(ur, int64(readLen)))
+ require.NoError(t, err)
+ assert.Equal(t, data[:readLen], read)
+ terminationErr := errors.New("Terminated")
+ ur.Terminate(terminationErr)
+ read, err = io.ReadAll(ur)
+ assert.Equal(t, terminationErr, err)
+ assert.Len(t, read, 0)
+}
diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go
new file mode 100644
index 0000000..7ac4969
--- /dev/null
+++ b/internal/useragent/useragent.go
@@ -0,0 +1,6 @@
+package useragent
+
+import "github.com/containers/image/v5/version"
+
+// DefaultUserAgent is a value that should be used by User-Agent headers, unless the user specifically instructs us otherwise.
+var DefaultUserAgent = "containers/" + version.Version + " (github.com/containers/image)"