Skip to content

Commit 25558e6

Browse files
authored
ImageStore: Preserve source index mediaType on push (#750)
- Closes #749. - 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
1 parent 9205a76 commit 25558e6

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

Sources/Containerization/Image/ImageStore/ImageStore+Export.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@ extension ImageStore {
7878
// Lastly, we need to construct and push a new index, since we may
7979
// have pushed content only for specific platforms.
8080
let digest = SHA256.hash(data: localIndexData)
81+
// The descriptor's mediaType becomes the HTTP Content-Type in
82+
// RegistryClient.push and must match the mediaType field inside
83+
// localIndexData. Registries reject mismatches with MANIFEST_INVALID.
8184
let descriptor = Descriptor(
82-
mediaType: MediaTypes.index,
85+
mediaType: index.mediaType,
8386
digest: digest.digestString,
8487
size: Int64(localIndexData.count))
8588
let stream = ReadStream(data: localIndexData)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerizationExtras
18+
import ContainerizationOCI
19+
import Crypto
20+
import Foundation
21+
import NIO
22+
import Testing
23+
24+
@testable import Containerization
25+
26+
@Suite
27+
final class ExportOperationTests {
28+
@Test(arguments: [MediaTypes.dockerManifestList, MediaTypes.index])
29+
func testIndexPushMediaTypeMatchesBody(_ sourceMediaType: String) async throws {
30+
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
31+
defer { try? FileManager.default.removeItem(at: dir) }
32+
33+
let cs = try LocalContentStore(path: dir)
34+
35+
// Opaque child mediaType so ExportOperation's recursion stops here
36+
// and we don't have to seed config/layer blobs.
37+
let opaqueType = "application/vnd.test.opaque.v1+json"
38+
let childData = Data("child-amd64".utf8)
39+
let childDigest = SHA256.hash(data: childData).digestString
40+
let childDesc = Descriptor(
41+
mediaType: opaqueType,
42+
digest: childDigest,
43+
size: Int64(childData.count),
44+
platform: Platform(arch: "amd64", os: "linux"))
45+
46+
let index = Index(mediaType: sourceMediaType, manifests: [childDesc])
47+
let indexData = try JSONEncoder().encode(index)
48+
let indexDigest = SHA256.hash(data: indexData).digestString
49+
50+
try await cs.ingest { ingestDir in
51+
for (digest, data) in [(childDigest, childData), (indexDigest, indexData)] {
52+
let path = ingestDir.appendingPathComponent(digest.trimmingDigestPrefix)
53+
try data.write(to: path)
54+
}
55+
}
56+
57+
let indexDesc = Descriptor(
58+
mediaType: sourceMediaType,
59+
digest: indexDigest,
60+
size: Int64(indexData.count))
61+
62+
let capture = CapturingContentClient()
63+
let op = ImageStore.ExportOperation(
64+
name: "test/repo", tag: "v1", contentStore: cs, client: capture)
65+
let pushed = try await op.export(index: indexDesc, platforms: { _ in true })
66+
67+
#expect(pushed.mediaType == sourceMediaType)
68+
69+
let indexPush = try #require(
70+
capture.pushes.first(where: { $0.descriptor.digest == pushed.digest }))
71+
#expect(indexPush.descriptor.mediaType == sourceMediaType)
72+
let pushedIndex = try JSONDecoder().decode(Index.self, from: indexPush.body)
73+
#expect(pushedIndex.mediaType == sourceMediaType)
74+
}
75+
}
76+
77+
private final class CapturingContentClient: ContentClient, @unchecked Sendable {
78+
struct Push: Sendable {
79+
let descriptor: Descriptor
80+
let body: Data
81+
}
82+
83+
private let lock = NSLock()
84+
private var _pushes: [Push] = []
85+
86+
var pushes: [Push] {
87+
lock.withLock { _pushes }
88+
}
89+
90+
private struct NotImplemented: Error {}
91+
92+
func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
93+
throw NotImplemented()
94+
}
95+
96+
func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
97+
throw NotImplemented()
98+
}
99+
100+
func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
101+
throw NotImplemented()
102+
}
103+
104+
func push<T: Sendable & AsyncSequence>(
105+
name: String,
106+
ref: String,
107+
descriptor: Descriptor,
108+
streamGenerator: () throws -> T,
109+
progress: ProgressHandler?
110+
) async throws where T.Element == ByteBuffer {
111+
let stream = try streamGenerator()
112+
var data = Data()
113+
for try await buf in stream {
114+
data.append(contentsOf: buf.readableBytesView)
115+
}
116+
lock.withLock {
117+
_pushes.append(Push(descriptor: descriptor, body: data))
118+
}
119+
}
120+
}

0 commit comments

Comments
 (0)