From 01a5123e009c0d5677b5cc260c2e3fd3b9aedd4e Mon Sep 17 00:00:00 2001 From: David Palma Date: Tue, 19 May 2026 22:02:14 -0700 Subject: [PATCH] ImageStore: Preserve source index mediaType on push ExportOperation hardcoded the pushed index descriptor's mediaType to the OCI image index type. RegistryClient.push uses that descriptor's mediaType as the HTTP Content-Type header. When the source index was in Docker manifest.list.v2+json format (the common case for images pulled from Docker Hub and other public registries), the body's embedded mediaType field disagreed with the header, and OCI registries rejected the index PUT with HTTP 400 MANIFEST_INVALID. Use the source index's mediaType for the pushed descriptor so the header always matches the body. Per-architecture child manifests are unaffected because they were already pushed with their actual mediaType. Add a parameterized unit test for ExportOperation.export covering both Docker manifest.list --- .../Image/ImageStore/ImageStore+Export.swift | 5 +- .../ImageTests/ExportOperationTests.swift | 120 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 Tests/ContainerizationTests/ImageTests/ExportOperationTests.swift diff --git a/Sources/Containerization/Image/ImageStore/ImageStore+Export.swift b/Sources/Containerization/Image/ImageStore/ImageStore+Export.swift index 24af8bd2..916285f6 100644 --- a/Sources/Containerization/Image/ImageStore/ImageStore+Export.swift +++ b/Sources/Containerization/Image/ImageStore/ImageStore+Export.swift @@ -78,8 +78,11 @@ extension ImageStore { // Lastly, we need to construct and push a new index, since we may // have pushed content only for specific platforms. let digest = SHA256.hash(data: localIndexData) + // The descriptor's mediaType becomes the HTTP Content-Type in + // RegistryClient.push and must match the mediaType field inside + // localIndexData. Registries reject mismatches with MANIFEST_INVALID. let descriptor = Descriptor( - mediaType: MediaTypes.index, + mediaType: index.mediaType, digest: digest.digestString, size: Int64(localIndexData.count)) let stream = ReadStream(data: localIndexData) diff --git a/Tests/ContainerizationTests/ImageTests/ExportOperationTests.swift b/Tests/ContainerizationTests/ImageTests/ExportOperationTests.swift new file mode 100644 index 00000000..9120e578 --- /dev/null +++ b/Tests/ContainerizationTests/ImageTests/ExportOperationTests.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project 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 ContainerizationExtras +import ContainerizationOCI +import Crypto +import Foundation +import NIO +import Testing + +@testable import Containerization + +@Suite +final class ExportOperationTests { + @Test(arguments: [MediaTypes.dockerManifestList, MediaTypes.index]) + func testIndexPushMediaTypeMatchesBody(_ sourceMediaType: String) async throws { + let dir = FileManager.default.uniqueTemporaryDirectory(create: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let cs = try LocalContentStore(path: dir) + + // Opaque child mediaType so ExportOperation's recursion stops here + // and we don't have to seed config/layer blobs. + let opaqueType = "application/vnd.test.opaque.v1+json" + let childData = Data("child-amd64".utf8) + let childDigest = SHA256.hash(data: childData).digestString + let childDesc = Descriptor( + mediaType: opaqueType, + digest: childDigest, + size: Int64(childData.count), + platform: Platform(arch: "amd64", os: "linux")) + + let index = Index(mediaType: sourceMediaType, manifests: [childDesc]) + let indexData = try JSONEncoder().encode(index) + let indexDigest = SHA256.hash(data: indexData).digestString + + try await cs.ingest { ingestDir in + for (digest, data) in [(childDigest, childData), (indexDigest, indexData)] { + let path = ingestDir.appendingPathComponent(digest.trimmingDigestPrefix) + try data.write(to: path) + } + } + + let indexDesc = Descriptor( + mediaType: sourceMediaType, + digest: indexDigest, + size: Int64(indexData.count)) + + let capture = CapturingContentClient() + let op = ImageStore.ExportOperation( + name: "test/repo", tag: "v1", contentStore: cs, client: capture) + let pushed = try await op.export(index: indexDesc, platforms: { _ in true }) + + #expect(pushed.mediaType == sourceMediaType) + + let indexPush = try #require( + capture.pushes.first(where: { $0.descriptor.digest == pushed.digest })) + #expect(indexPush.descriptor.mediaType == sourceMediaType) + let pushedIndex = try JSONDecoder().decode(Index.self, from: indexPush.body) + #expect(pushedIndex.mediaType == sourceMediaType) + } +} + +private final class CapturingContentClient: ContentClient, @unchecked Sendable { + struct Push: Sendable { + let descriptor: Descriptor + let body: Data + } + + private let lock = NSLock() + private var _pushes: [Push] = [] + + var pushes: [Push] { + lock.withLock { _pushes } + } + + private struct NotImplemented: Error {} + + func fetch(name: String, descriptor: Descriptor) async throws -> T { + throw NotImplemented() + } + + func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) { + throw NotImplemented() + } + + func fetchData(name: String, descriptor: Descriptor) async throws -> Data { + throw NotImplemented() + } + + func push( + name: String, + ref: String, + descriptor: Descriptor, + streamGenerator: () throws -> T, + progress: ProgressHandler? + ) async throws where T.Element == ByteBuffer { + let stream = try streamGenerator() + var data = Data() + for try await buf in stream { + data.append(contentsOf: buf.readableBytesView) + } + lock.withLock { + _pushes.append(Push(descriptor: descriptor, body: data)) + } + } +}