@@ -21,6 +21,7 @@ import ContainerizationIO
2121import Crypto
2222import Foundation
2323import NIO
24+ import NIOCore
2425import Synchronization
2526import Testing
2627
@@ -166,113 +167,125 @@ struct OCIClientTests: ~Copyable {
166167 #expect(done)
167168 }
168169
169- @Test(.disabled("External users cannot push images, disable while we find a better solution"))
170- func pushIndex() async throws {
171- let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
172- let indexDescriptor = try await client.resolve(name: "apple/containerization/emptyimage", tag: "0.0.1")
173- let index: Index = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: indexDescriptor)
174-
175- let platform = Platform(arch: "amd64", os: "linux")
176-
177- var manifestDescriptor: Descriptor?
178- for m in index.manifests where m.platform == platform {
179- manifestDescriptor = m
180- break
181- }
182-
183- #expect(manifestDescriptor != nil)
184-
185- let manifest: Manifest = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifestDescriptor!)
186- let imgConfig: Image = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifest.config)
187-
188- let layer = try #require(manifest.layers.first)
189- let blobPath = contentPath.appendingPathComponent(layer.digest)
190- let outputStream = OutputStream(toFileAtPath: blobPath.path, append: false)
191- #expect(outputStream != nil)
170+ @Test func pushIndexWithMock() async throws {
171+ // Create a mock client for testing push operations
172+ let mockClient = MockRegistryClient()
173+
174+ // Create test data for an index and its components
175+ let testLayerData = "test layer content".data(using: .utf8)!
176+ let layerDigest = SHA256.hash(data: testLayerData)
177+ let layerDescriptor = Descriptor(
178+ mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
179+ digest: "sha256:\(layerDigest.hexString)",
180+ size: Int64(testLayerData.count)
181+ )
192182
193- try await outputStream!.withThrowingOpeningStream {
194- try await client.fetchBlob(name: "apple/containerization/emptyimage", descriptor: layer) { (expected, body) in
195- var received: Int64 = 0
196- for try await buffer in body {
197- received += Int64(buffer.readableBytes)
183+ // Create test image config
184+ let imageConfig = Image(
185+ architecture: "amd64",
186+ os: "linux",
187+ config: ImageConfig(labels: ["test": "value"]),
188+ rootfs: Rootfs(type: "layers", diffIDs: ["sha256:\(layerDigest.hexString)"])
189+ )
190+ let configData = try JSONEncoder().encode(imageConfig)
191+ let configDigest = SHA256.hash(data: configData)
192+ let configDescriptor = Descriptor(
193+ mediaType: "application/vnd.docker.container.image.v1+json",
194+ digest: "sha256:\(configDigest.hexString)",
195+ size: Int64(configData.count)
196+ )
198197
199- buffer.withUnsafeReadableBytes { pointer in
200- let unsafeBufferPointer = pointer.bindMemory(to: UInt8.self)
201- if let addr = unsafeBufferPointer.baseAddress {
202- outputStream!.write(addr, maxLength: buffer.readableBytes)
203- }
204- }
205- }
198+ // Create test manifest
199+ let manifest = Manifest(
200+ schemaVersion: 2,
201+ mediaType: "application/vnd.docker.distribution.manifest.v2+json",
202+ config: configDescriptor,
203+ layers: [layerDescriptor]
204+ )
205+ let manifestData = try JSONEncoder().encode(manifest)
206+ let manifestDigest = SHA256.hash(data: manifestData)
207+ let manifestDescriptor = Descriptor(
208+ mediaType: "application/vnd.docker.distribution.manifest.v2+json",
209+ digest: "sha256:\(manifestDigest.hexString)",
210+ size: Int64(manifestData.count),
211+ platform: Platform(arch: "amd64", os: "linux")
212+ )
206213
207- #expect(received == expected)
208- }
209- }
214+ // Create test index
215+ let index = Index(
216+ schemaVersion: 2,
217+ mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
218+ manifests: [manifestDescriptor]
219+ )
210220
211- let name = "apple/ test-images /image-push "
221+ let name = "test/image"
212222 let ref = "latest"
213223
214- // Push the layer first.
215- do {
216- let content = try LocalContent(path: blobPath)
217- let generator = {
218- let stream = try ReadStream(url: content.path)
219- try stream.reset()
220- return stream.stream
221- }
222- try await client.push(name: name, ref: ref, descriptor: layer, streamGenerator: generator, progress: nil)
223- } catch let err as ContainerizationError {
224- guard err.code == .exists else {
225- throw err
226- }
227- }
224+ // Test pushing individual components using the mock client
228225
229- // Push the image configuration.
230- var imgConfigDesc: Descriptor?
231- do {
232- imgConfigDesc = try await self.pushDescriptor(
233- client: client,
234- name: name,
235- ref: ref,
236- content: imgConfig,
237- baseDescriptor: manifest.config
238- )
239- } catch let err as ContainerizationError {
240- guard err.code != .exists else {
241- return
242- }
243- throw err
244- }
226+ // Push layer
227+ let layerStream = TestByteBufferSequence(data: testLayerData)
228+ try await mockClient.push(
229+ name: name,
230+ ref: ref,
231+ descriptor: layerDescriptor,
232+ streamGenerator: { layerStream },
233+ progress: nil as ProgressHandler?
234+ )
245235
246- // Push the image manifest.
247- let newManifest = Manifest(
248- schemaVersion: manifest.schemaVersion,
249- mediaType: manifest.mediaType!,
250- config: imgConfigDesc!,
251- layers: manifest.layers,
252- annotations: manifest.annotations
236+ // Push config
237+ let configStream = TestByteBufferSequence(data: configData)
238+ try await mockClient.push(
239+ name: name,
240+ ref: ref,
241+ descriptor: configDescriptor,
242+ streamGenerator: { configStream },
243+ progress: nil as ProgressHandler?
253244 )
254- let manifestDesc = try await self.pushDescriptor(
255- client: client,
245+
246+ // Push manifest
247+ let manifestStream = TestByteBufferSequence(data: manifestData)
248+ try await mockClient.push(
256249 name: name,
257250 ref: ref,
258- content: newManifest,
259- baseDescriptor: manifestDescriptor!
251+ descriptor: manifestDescriptor,
252+ streamGenerator: { manifestStream },
253+ progress: nil as ProgressHandler?
260254 )
261255
262- // Push the index.
263- let newIndex = Index(
264- schemaVersion: index.schemaVersion,
265- mediaType: index.mediaType,
266- manifests: [manifestDesc],
267- annotations: index.annotations
256+ // Push index
257+ let indexData = try JSONEncoder().encode(index)
258+ let indexDigest = SHA256.hash(data: indexData)
259+ let indexDescriptor = Descriptor(
260+ mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
261+ digest: "sha256:\(indexDigest.hexString)",
262+ size: Int64(indexData.count)
268263 )
269- try await self.pushDescriptor(
270- client: client,
264+
265+ let indexStream = TestByteBufferSequence(data: indexData)
266+ try await mockClient.push(
271267 name: name,
272268 ref: ref,
273- content: newIndex,
274- baseDescriptor: indexDescriptor
269+ descriptor: indexDescriptor,
270+ streamGenerator: { indexStream },
271+ progress: nil as ProgressHandler?
275272 )
273+
274+ // Verify all push operations were recorded
275+ #expect(mockClient.pushCalls.count == 4)
276+
277+ // Verify content integrity
278+ let storedLayerData = mockClient.getPushedContent(name: name, descriptor: layerDescriptor)
279+ #expect(storedLayerData == testLayerData)
280+
281+ let storedConfigData = mockClient.getPushedContent(name: name, descriptor: configDescriptor)
282+ #expect(storedConfigData == configData)
283+
284+ let storedManifestData = mockClient.getPushedContent(name: name, descriptor: manifestDescriptor)
285+ #expect(storedManifestData == manifestData)
286+
287+ let storedIndexData = mockClient.getPushedContent(name: name, descriptor: indexDescriptor)
288+ #expect(storedIndexData == indexData)
276289 }
277290
278291 @Test func resolveWithRetry() async throws {
@@ -343,7 +356,7 @@ struct OCIClientTests: ~Copyable {
343356 ref: ref,
344357 descriptor: descriptor,
345358 streamGenerator: generator,
346- progress: nil
359+ progress: nil as ProgressHandler?
347360 )
348361 return descriptor
349362 }
@@ -363,4 +376,143 @@ extension SHA256.Digest {
363376 let parts = self.description.split(separator: ": ")
364377 return "sha256:\(parts[1])"
365378 }
379+
380+ var hexString: String {
381+ self.compactMap { String(format: "%02x", $0) }.joined()
382+ }
383+ }
384+
385+ // Helper to create ByteBuffer sequences for testing
386+ struct TestByteBufferSequence: Sendable, AsyncSequence {
387+ typealias Element = ByteBuffer
388+
389+ private let data: Data
390+
391+ init(data: Data) {
392+ self.data = data
393+ }
394+
395+ func makeAsyncIterator() -> AsyncIterator {
396+ AsyncIterator(data: data)
397+ }
398+
399+ struct AsyncIterator: AsyncIteratorProtocol {
400+ private let data: Data
401+ private var sent = false
402+
403+ init(data: Data) {
404+ self.data = data
405+ }
406+
407+ mutating func next() async throws -> ByteBuffer? {
408+ guard !sent else { return nil }
409+ sent = true
410+
411+ var buffer = ByteBufferAllocator().buffer(capacity: data.count)
412+ buffer.writeBytes(data)
413+ return buffer
414+ }
415+ }
416+ }
417+
418+ // Helper class to create a mock ContentClient for testing
419+ final class MockRegistryClient: ContentClient, @unchecked Sendable {
420+ private var pushedContent: [String: [Descriptor: Data]] = [:]
421+ private var fetchableContent: [String: [Descriptor: Data]] = [:]
422+
423+ // Track push operations for verification
424+ var pushCalls: [(name: String, ref: String, descriptor: Descriptor)] = []
425+
426+ func addFetchableContent<T: Codable>(name: String, descriptor: Descriptor, content: T) throws {
427+ let data = try JSONEncoder().encode(content)
428+ if fetchableContent[name] == nil {
429+ fetchableContent[name] = [:]
430+ }
431+ fetchableContent[name]![descriptor] = data
432+ }
433+
434+ func addFetchableData(name: String, descriptor: Descriptor, data: Data) {
435+ if fetchableContent[name] == nil {
436+ fetchableContent[name] = [:]
437+ }
438+ fetchableContent[name]![descriptor] = data
439+ }
440+
441+ func getPushedContent(name: String, descriptor: Descriptor) -> Data? {
442+ pushedContent[name]?[descriptor]
443+ }
444+
445+ // MARK: - ContentClient Implementation
446+
447+ func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
448+ guard let imageContent = fetchableContent[name],
449+ let data = imageContent[descriptor]
450+ else {
451+ throw ContainerizationError(.notFound, message: "Content not found for \(name) with descriptor \(descriptor.digest)")
452+ }
453+
454+ return try JSONDecoder().decode(T.self, from: data)
455+ }
456+
457+ func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256.Digest) {
458+ guard let imageContent = fetchableContent[name],
459+ let data = imageContent[descriptor]
460+ else {
461+ throw ContainerizationError(.notFound, message: "Blob not found for \(name) with descriptor \(descriptor.digest)")
462+ }
463+
464+ try data.write(to: file)
465+ let digest = SHA256.hash(data: data)
466+ return (Int64(data.count), digest)
467+ }
468+
469+ func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
470+ guard let imageContent = fetchableContent[name],
471+ let data = imageContent[descriptor]
472+ else {
473+ throw ContainerizationError(.notFound, message: "Data not found for \(name) with descriptor \(descriptor.digest)")
474+ }
475+
476+ return data
477+ }
478+
479+ func push<T: Sendable & AsyncSequence>(
480+ name: String,
481+ ref: String,
482+ descriptor: Descriptor,
483+ streamGenerator: () throws -> T,
484+ progress: ProgressHandler?
485+ ) async throws where T.Element == ByteBuffer {
486+ // Record the push call for verification
487+ pushCalls.append((name: name, ref: ref, descriptor: descriptor))
488+
489+ // Simulate reading the stream and storing the data
490+ let stream = try streamGenerator()
491+ var data = Data()
492+
493+ for try await buffer in stream {
494+ data.append(contentsOf: buffer.readableBytesView)
495+ }
496+
497+ // Verify the pushed data matches the expected descriptor
498+ let actualDigest = SHA256.hash(data: data)
499+ guard descriptor.digest == "sha256:\(actualDigest.hexString)" else {
500+ throw ContainerizationError(.invalidArgument, message: "Digest mismatch: expected \(descriptor.digest), got sha256:\(actualDigest.hexString)")
501+ }
502+
503+ guard data.count == descriptor.size else {
504+ throw ContainerizationError(.invalidArgument, message: "Size mismatch: expected \(descriptor.size), got \(data.count)")
505+ }
506+
507+ // Store the pushed content
508+ if pushedContent[name] == nil {
509+ pushedContent[name] = [:]
510+ }
511+ pushedContent[name]![descriptor] = data
512+
513+ // Simulate progress reporting
514+ if let progress = progress {
515+ await progress(Int64(data.count), Int64(data.count))
516+ }
517+ }
366518}
0 commit comments