|
| 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