Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/ContainerizationOCI/Descriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import Foundation
/// Descriptor describes the disposition of targeted content.
/// This structure provides `application/vnd.oci.descriptor.v1+json` mediatype
/// when marshalled to JSON.
public struct Descriptor: Codable, Sendable, Equatable {
public struct Descriptor: Codable, Sendable, Equatable, Hashable {
/// mediaType is the media type of the object this schema refers to.
public let mediaType: String

Expand Down
334 changes: 243 additions & 91 deletions Tests/ContainerizationOCITests/RegistryClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import ContainerizationIO
import Crypto
import Foundation
import NIO
import NIOCore
import Synchronization
import Testing

Expand Down Expand Up @@ -166,113 +167,125 @@ struct OCIClientTests: ~Copyable {
#expect(done)
}

@Test(.disabled("External users cannot push images, disable while we find a better solution"))
func pushIndex() async throws {
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
let indexDescriptor = try await client.resolve(name: "apple/containerization/emptyimage", tag: "0.0.1")
let index: Index = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: indexDescriptor)

let platform = Platform(arch: "amd64", os: "linux")

var manifestDescriptor: Descriptor?
for m in index.manifests where m.platform == platform {
manifestDescriptor = m
break
}

#expect(manifestDescriptor != nil)

let manifest: Manifest = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifestDescriptor!)
let imgConfig: Image = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifest.config)

let layer = try #require(manifest.layers.first)
let blobPath = contentPath.appendingPathComponent(layer.digest)
let outputStream = OutputStream(toFileAtPath: blobPath.path, append: false)
#expect(outputStream != nil)
@Test func pushIndexWithMock() async throws {
// Create a mock client for testing push operations
let mockClient = MockRegistryClient()

// Create test data for an index and its components
let testLayerData = "test layer content".data(using: .utf8)!
let layerDigest = SHA256.hash(data: testLayerData)
let layerDescriptor = Descriptor(
mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
digest: "sha256:\(layerDigest.hexString)",
size: Int64(testLayerData.count)
)

try await outputStream!.withThrowingOpeningStream {
try await client.fetchBlob(name: "apple/containerization/emptyimage", descriptor: layer) { (expected, body) in
var received: Int64 = 0
for try await buffer in body {
received += Int64(buffer.readableBytes)
// Create test image config
let imageConfig = Image(
architecture: "amd64",
os: "linux",
config: ImageConfig(labels: ["test": "value"]),
rootfs: Rootfs(type: "layers", diffIDs: ["sha256:\(layerDigest.hexString)"])
)
let configData = try JSONEncoder().encode(imageConfig)
let configDigest = SHA256.hash(data: configData)
let configDescriptor = Descriptor(
mediaType: "application/vnd.docker.container.image.v1+json",
digest: "sha256:\(configDigest.hexString)",
size: Int64(configData.count)
)

buffer.withUnsafeReadableBytes { pointer in
let unsafeBufferPointer = pointer.bindMemory(to: UInt8.self)
if let addr = unsafeBufferPointer.baseAddress {
outputStream!.write(addr, maxLength: buffer.readableBytes)
}
}
}
// Create test manifest
let manifest = Manifest(
schemaVersion: 2,
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
config: configDescriptor,
layers: [layerDescriptor]
)
let manifestData = try JSONEncoder().encode(manifest)
let manifestDigest = SHA256.hash(data: manifestData)
let manifestDescriptor = Descriptor(
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
digest: "sha256:\(manifestDigest.hexString)",
size: Int64(manifestData.count),
platform: Platform(arch: "amd64", os: "linux")
)

#expect(received == expected)
}
}
// Create test index
let index = Index(
schemaVersion: 2,
mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
manifests: [manifestDescriptor]
)

let name = "apple/test-images/image-push"
let name = "test/image"
let ref = "latest"

// Push the layer first.
do {
let content = try LocalContent(path: blobPath)
let generator = {
let stream = try ReadStream(url: content.path)
try stream.reset()
return stream.stream
}
try await client.push(name: name, ref: ref, descriptor: layer, streamGenerator: generator, progress: nil)
} catch let err as ContainerizationError {
guard err.code == .exists else {
throw err
}
}
// Test pushing individual components using the mock client

// Push the image configuration.
var imgConfigDesc: Descriptor?
do {
imgConfigDesc = try await self.pushDescriptor(
client: client,
name: name,
ref: ref,
content: imgConfig,
baseDescriptor: manifest.config
)
} catch let err as ContainerizationError {
guard err.code != .exists else {
return
}
throw err
}
// Push layer
let layerStream = TestByteBufferSequence(data: testLayerData)
try await mockClient.push(
name: name,
ref: ref,
descriptor: layerDescriptor,
streamGenerator: { layerStream },
progress: nil as ProgressHandler?
)

// Push the image manifest.
let newManifest = Manifest(
schemaVersion: manifest.schemaVersion,
mediaType: manifest.mediaType!,
config: imgConfigDesc!,
layers: manifest.layers,
annotations: manifest.annotations
// Push config
let configStream = TestByteBufferSequence(data: configData)
try await mockClient.push(
name: name,
ref: ref,
descriptor: configDescriptor,
streamGenerator: { configStream },
progress: nil as ProgressHandler?
)
let manifestDesc = try await self.pushDescriptor(
client: client,

// Push manifest
let manifestStream = TestByteBufferSequence(data: manifestData)
try await mockClient.push(
name: name,
ref: ref,
content: newManifest,
baseDescriptor: manifestDescriptor!
descriptor: manifestDescriptor,
streamGenerator: { manifestStream },
progress: nil as ProgressHandler?
)

// Push the index.
let newIndex = Index(
schemaVersion: index.schemaVersion,
mediaType: index.mediaType,
manifests: [manifestDesc],
annotations: index.annotations
// Push index
let indexData = try JSONEncoder().encode(index)
let indexDigest = SHA256.hash(data: indexData)
let indexDescriptor = Descriptor(
mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
digest: "sha256:\(indexDigest.hexString)",
size: Int64(indexData.count)
)
try await self.pushDescriptor(
client: client,

let indexStream = TestByteBufferSequence(data: indexData)
try await mockClient.push(
name: name,
ref: ref,
content: newIndex,
baseDescriptor: indexDescriptor
descriptor: indexDescriptor,
streamGenerator: { indexStream },
progress: nil as ProgressHandler?
)

// Verify all push operations were recorded
#expect(mockClient.pushCalls.count == 4)

// Verify content integrity
let storedLayerData = mockClient.getPushedContent(name: name, descriptor: layerDescriptor)
#expect(storedLayerData == testLayerData)

let storedConfigData = mockClient.getPushedContent(name: name, descriptor: configDescriptor)
#expect(storedConfigData == configData)

let storedManifestData = mockClient.getPushedContent(name: name, descriptor: manifestDescriptor)
#expect(storedManifestData == manifestData)

let storedIndexData = mockClient.getPushedContent(name: name, descriptor: indexDescriptor)
#expect(storedIndexData == indexData)
}

@Test func resolveWithRetry() async throws {
Expand Down Expand Up @@ -343,7 +356,7 @@ struct OCIClientTests: ~Copyable {
ref: ref,
descriptor: descriptor,
streamGenerator: generator,
progress: nil
progress: nil as ProgressHandler?
)
return descriptor
}
Expand All @@ -363,4 +376,143 @@ extension SHA256.Digest {
let parts = self.description.split(separator: ": ")
return "sha256:\(parts[1])"
}

var hexString: String {
self.compactMap { String(format: "%02x", $0) }.joined()
}
}

// Helper to create ByteBuffer sequences for testing
struct TestByteBufferSequence: Sendable, AsyncSequence {
typealias Element = ByteBuffer

private let data: Data

init(data: Data) {
self.data = data
}

func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(data: data)
}

struct AsyncIterator: AsyncIteratorProtocol {
private let data: Data
private var sent = false

init(data: Data) {
self.data = data
}

mutating func next() async throws -> ByteBuffer? {
guard !sent else { return nil }
sent = true

var buffer = ByteBufferAllocator().buffer(capacity: data.count)
buffer.writeBytes(data)
return buffer
}
}
}

// Helper class to create a mock ContentClient for testing
final class MockRegistryClient: ContentClient, @unchecked Sendable {
private var pushedContent: [String: [Descriptor: Data]] = [:]
private var fetchableContent: [String: [Descriptor: Data]] = [:]

// Track push operations for verification
var pushCalls: [(name: String, ref: String, descriptor: Descriptor)] = []

func addFetchableContent<T: Codable>(name: String, descriptor: Descriptor, content: T) throws {
let data = try JSONEncoder().encode(content)
if fetchableContent[name] == nil {
fetchableContent[name] = [:]
}
fetchableContent[name]![descriptor] = data
}

func addFetchableData(name: String, descriptor: Descriptor, data: Data) {
if fetchableContent[name] == nil {
fetchableContent[name] = [:]
}
fetchableContent[name]![descriptor] = data
}

func getPushedContent(name: String, descriptor: Descriptor) -> Data? {
pushedContent[name]?[descriptor]
}

// MARK: - ContentClient Implementation

func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
guard let imageContent = fetchableContent[name],
let data = imageContent[descriptor]
else {
throw ContainerizationError(.notFound, message: "Content not found for \(name) with descriptor \(descriptor.digest)")
}

return try JSONDecoder().decode(T.self, from: data)
}

func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256.Digest) {
guard let imageContent = fetchableContent[name],
let data = imageContent[descriptor]
else {
throw ContainerizationError(.notFound, message: "Blob not found for \(name) with descriptor \(descriptor.digest)")
}

try data.write(to: file)
let digest = SHA256.hash(data: data)
return (Int64(data.count), digest)
}

func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
guard let imageContent = fetchableContent[name],
let data = imageContent[descriptor]
else {
throw ContainerizationError(.notFound, message: "Data not found for \(name) with descriptor \(descriptor.digest)")
}

return data
}

func push<T: Sendable & AsyncSequence>(
name: String,
ref: String,
descriptor: Descriptor,
streamGenerator: () throws -> T,
progress: ProgressHandler?
) async throws where T.Element == ByteBuffer {
// Record the push call for verification
pushCalls.append((name: name, ref: ref, descriptor: descriptor))

// Simulate reading the stream and storing the data
let stream = try streamGenerator()
var data = Data()

for try await buffer in stream {
data.append(contentsOf: buffer.readableBytesView)
}

// Verify the pushed data matches the expected descriptor
let actualDigest = SHA256.hash(data: data)
guard descriptor.digest == "sha256:\(actualDigest.hexString)" else {
throw ContainerizationError(.invalidArgument, message: "Digest mismatch: expected \(descriptor.digest), got sha256:\(actualDigest.hexString)")
}

guard data.count == descriptor.size else {
throw ContainerizationError(.invalidArgument, message: "Size mismatch: expected \(descriptor.size), got \(data.count)")
}

// Store the pushed content
if pushedContent[name] == nil {
pushedContent[name] = [:]
}
pushedContent[name]![descriptor] = data

// Simulate progress reporting
if let progress = progress {
await progress(Int64(data.count), Int64(data.count))
}
}
}
Loading
Loading