diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-16 17:06:25 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-16 17:06:25 +0000 |
commit | f115bb55d7eec53ad9ce2505dec9a7e0eed12536 (patch) | |
tree | 5c161bdd6ad6304914773103edbbe16d403d3d18 /internal | |
parent | Initial commit. (diff) | |
download | golang-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')
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(©), 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(©), 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(©), 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 Binary files differnew file mode 100644 index 0000000..f892721 --- /dev/null +++ b/internal/manifest/testdata/non-json.manifest.json 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)" |