chore: import upstream snapshot with attribution
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,50 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 Foundation
/// Abstraction for returning a token needed for logging into an OCI compliant registry.
public protocol Authentication: Sendable {
func token() async throws -> String
}
/// Type representing authentication information for client to access the registry.
public struct BasicAuthentication: Authentication {
/// The username for the authentication.
let username: String
/// The password or identity token for the user.
let password: String
public init(username: String, password: String) {
self.username = username
self.password = password
}
/// Get a token using the provided username and password. This will be a
/// base64 encoded string of the username and password delimited by a colon.
public func token() async throws -> String {
let credentials = "\(username):\(password)"
if let authenticationData = credentials.data(using: .utf8)?.base64EncodedString() {
return "Basic \(authenticationData)"
}
throw Error.invalidCredentials
}
/// `BasicAuthentication` errors.
public enum Error: Swift.Error {
case invalidCredentials
}
}
@@ -0,0 +1,142 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Foundation
import ContainerizationOS
/// Helper type to lookup registry related values in the macOS keychain.
public struct KeychainHelper: Sendable {
private let securityDomain: String
private let accessGroup: String?
/// Create a new keychain helper.
/// - Parameters:
/// - securityDomain: The security domain used to fetch registry entries in the keychain.
/// - accessGroup: If present, the access group used to fetch registry entries in the keychain.
public init(securityDomain: String, accessGroup: String? = nil) {
self.securityDomain = securityDomain
self.accessGroup = accessGroup
}
/// Lookup authentication data for a given registry hostname.
/// - Parameters:
/// - hostname: The hostname for the registry.
/// - Returns: The authentication object for the registry.
/// - Throws: An error if the keychain query fails.
public func lookup(hostname: String) throws -> Authentication {
let kq = KeychainQuery()
do {
guard
let fetched = try kq.get(
securityDomain: self.securityDomain,
accessGroup: self.accessGroup,
hostname: hostname)
else {
throw Self.Error.keyNotFound
}
return BasicAuthentication(
username: fetched.username,
password: fetched.password
)
} catch let err as KeychainQuery.Error {
switch err {
case .keyNotPresent(_):
throw Self.Error.keyNotFound
default:
throw Self.Error.queryError("query failure: \(String(describing: err))")
}
}
}
/// Lists all registry entries for this security domain.
/// - Returns: An array of registry metadata for each matching entry, or an empty array if none are found.
/// - Throws: An error if the keychain query fails.
public func list() throws -> [RegistryInfo] {
let kq = KeychainQuery()
return try kq.list(securityDomain: self.securityDomain, accessGroup: self.accessGroup)
}
/// Delete authorization data for a given hostname from the keychain.
/// - Parameters:
/// - hostname: The hostname for the registry.
/// - Throws: An error if the keychain query fails.
public func delete(hostname: String) throws {
let kq = KeychainQuery()
try kq.delete(securityDomain: self.securityDomain, accessGroup: self.accessGroup, hostname: hostname)
}
/// Save authorization data for a given hostname to the keychain.
/// - Parameters:
/// - hostname: The hostname for the registry.
/// - username: The username to present to the registry.
/// - password: The password to present to the registry.
/// - Throws: An error if the keychain query fails or returns unexpected data.
public func save(hostname: String, username: String, password: String) throws {
let kq = KeychainQuery()
try kq.save(
securityDomain: self.securityDomain,
accessGroup: self.accessGroup,
hostname: hostname,
username: username,
password: password
)
}
/// Prompt for authorization data for a given hostname to be saved to the keychain.
/// This will cause the current terminal to enter a password prompt state where
/// key strokes are hidden.
public func credentialPrompt(hostname: String) throws -> Authentication {
let username = try userPrompt(hostname: hostname)
let password = try passwordPrompt()
return BasicAuthentication(username: username, password: password)
}
/// Prompts the current stdin for a username entry and then returns the value.
public func userPrompt(hostname: String) throws -> String {
print("Provide registry username \(hostname): ", terminator: "")
guard let username = readLine() else {
throw Self.Error.invalidInput
}
return username
}
/// Prompts the current stdin for a password entry and then returns the value.
/// This will cause the current stdin (if it is a terminal) to hide keystrokes
/// by disabling echo.
public func passwordPrompt() throws -> String {
print("Provide registry password: ", terminator: "")
let console = try Terminal.current
defer { console.tryReset() }
try console.disableEcho()
guard let password = readLine() else {
throw Self.Error.invalidInput
}
return password
}
}
extension KeychainHelper {
/// `KeychainHelper` errors.
public enum Error: Swift.Error {
case keyNotFound
case invalidInput
case queryError(String)
}
}
#endif
@@ -0,0 +1,242 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ContainerizationError
import ContainerizationExtras
import Crypto
import Foundation
import NIOCore
import NIOFoundationCompat
package final class LocalOCILayoutClient: ContentClient {
let cs: LocalContentStore
package init(root: URL) throws {
self.cs = try LocalContentStore(path: root)
}
private func _fetch(digest: String) async throws -> Content {
guard let c: Content = try await self.cs.get(digest: digest) else {
throw Error.missingContent(digest)
}
return c
}
private func calculateFileDigest(at url: URL) throws -> SHA256Digest {
let fileHandle = try FileHandle(forReadingFrom: url)
defer {
try? fileHandle.close()
}
var hasher = SHA256()
let chunkSize = Int(getpagesize()) * 1024
while true {
let chunk = fileHandle.readData(ofLength: chunkSize)
if chunk.isEmpty {
break
}
hasher.update(data: chunk)
}
return hasher.finalize()
}
package func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
let c = try await self._fetch(digest: descriptor.digest)
return try c.decode()
}
package func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
let c = try await self._fetch(digest: descriptor.digest)
let fileManager = FileManager.default
let filePath = file.absolutePath()
do {
let src = c.path
try fileManager.copyItem(at: src, to: file)
if let progress, let fileSize = fileManager.fileSize(atPath: filePath) {
await progress([
.addSize(fileSize)
])
}
} catch let error as NSError {
guard error.code == NSFileWriteFileExistsError else {
throw error
}
do {
let expectedDigest = try c.digest()
let existingDigest = try calculateFileDigest(at: file)
guard existingDigest.digestString == expectedDigest.digestString else {
throw ContainerizationError(
.internalError,
message:
"file \(filePath) exists but contains different content, expected digest: \(expectedDigest.digestString), existing digest: \(existingDigest.digestString)"
)
}
if let progress, let fileSize = fileManager.fileSize(atPath: filePath) {
await progress([
.addSize(fileSize)
])
}
} catch {
throw error
}
}
let size = try Int64(c.size())
let digest = try c.digest()
return (size, digest)
}
package func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
let c = try await self._fetch(digest: descriptor.digest)
return try c.data()
}
package func push<T: Sendable & AsyncSequence>(
name: String,
ref: String,
descriptor: Descriptor,
streamGenerator: () throws -> T,
progress: ProgressHandler?
) async throws where T.Element == ByteBuffer {
let input = try streamGenerator()
let (id, dir) = try await self.cs.newIngestSession()
do {
let into = dir.appendingPathComponent(descriptor.digest.trimmingDigestPrefix)
guard FileManager.default.createFile(atPath: into.path, contents: nil) else {
throw Error.cannotCreateFile
}
let fd = try FileHandle(forWritingTo: into)
defer {
try? fd.close()
}
var wrote = 0
var hasher = SHA256()
for try await buffer in input {
wrote += buffer.readableBytes
try fd.write(contentsOf: buffer.readableBytesView)
hasher.update(data: buffer.readableBytesView)
}
try await self.cs.completeIngestSession(id)
} catch {
try await self.cs.cancelIngestSession(id)
}
}
}
extension LocalOCILayoutClient {
private static let ociLayoutFileName = "oci-layout"
private static let ociLayoutVersionString = "imageLayoutVersion"
private static let ociLayoutIndexFileName = "index.json"
package func loadIndexFromOCILayout(directory: URL) throws -> ContainerizationOCI.Index {
let fm = FileManager.default
let decoder = JSONDecoder()
let ociLayoutFile = directory.appendingPathComponent(Self.ociLayoutFileName)
guard fm.fileExists(atPath: ociLayoutFile.absolutePath()) else {
throw ContainerizationError(.notFound, message: ociLayoutFile.absolutePath())
}
var data = try Data(contentsOf: ociLayoutFile)
let ociLayout = try decoder.decode([String: String].self, from: data)
guard ociLayout[Self.ociLayoutVersionString] != nil else {
throw ContainerizationError(.empty, message: "missing key \(Self.ociLayoutVersionString) in \(ociLayoutFile.absolutePath())")
}
let indexFile = directory.appendingPathComponent(Self.ociLayoutIndexFileName)
guard fm.fileExists(atPath: indexFile.absolutePath()) else {
throw ContainerizationError(.notFound, message: indexFile.absolutePath())
}
data = try Data(contentsOf: indexFile)
let index = try decoder.decode(ContainerizationOCI.Index.self, from: data)
return index
}
package func createOCILayoutStructure(directory: URL, manifests: [Descriptor]) throws {
let fm = FileManager.default
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
let ingestDir = directory.appendingPathComponent("ingest")
try? fm.removeItem(at: ingestDir)
let ociLayoutContent: [String: String] = [
Self.ociLayoutVersionString: "1.0.0"
]
var data = try encoder.encode(ociLayoutContent)
var p = directory.appendingPathComponent(Self.ociLayoutFileName).absolutePath()
guard fm.createFile(atPath: p, contents: data) else {
throw ContainerizationError(.internalError, message: "failed to create file \(p)")
}
let idx = ContainerizationOCI.Index(schemaVersion: 2, manifests: manifests)
data = try encoder.encode(idx)
p = directory.appendingPathComponent(Self.ociLayoutIndexFileName).absolutePath()
guard fm.createFile(atPath: p, contents: data) else {
throw ContainerizationError(.internalError, message: "failed to create file \(p)")
}
}
package func setImageReferenceAnnotation(descriptor: inout Descriptor, reference: String) {
var annotations = descriptor.annotations ?? [:]
annotations[AnnotationKeys.containerizationImageName] = reference
annotations[AnnotationKeys.containerdImageName] = reference
annotations[AnnotationKeys.openContainersImageName] = reference
descriptor.annotations = annotations
}
package func getImageReferencefromDescriptor(descriptor: Descriptor) -> String {
let annotations = descriptor.annotations
// Annotations here do not conform to the OCI image specification.
// The interpretation of the annotations "org.opencontainers.image.ref.name" and
// "io.containerd.image.name" is under debate:
// - OCI spec examples suggest it should be the image tag:
// https://github.com/opencontainers/image-spec/blob/fbb4662eb53b80bd38f7597406cf1211317768f0/image-layout.md?plain=1#L175
// - Buildkitd maintainers argue it should represent the full image name:
// https://github.com/moby/buildkit/issues/4615#issuecomment-2521810830
// Until a consensus is reached, the preference is given to "com.apple.containerization.image.name" and then to
// using "io.containerd.image.name" as it is the next safest choice
if let annotations {
if let name = annotations[AnnotationKeys.containerizationImageName] {
return name
}
if let name = annotations[AnnotationKeys.containerdImageName] {
return name
}
if let name = annotations[AnnotationKeys.openContainersImageName] {
return name
}
}
// Fallback: Generate digest-based reference for images without annotations
// This makes sure OCI spec compliance as annotations are optional
return "untagged@\(descriptor.digest)"
}
package enum Error: Swift.Error {
case missingContent(_ digest: String)
case unsupportedInput
case cannotCreateFile
}
}
@@ -0,0 +1,85 @@
//===----------------------------------------------------------------------===//
// 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 AsyncHTTPClient
import ContainerizationError
import Foundation
import NIOFoundationCompat
private struct CatalogResponse: Sendable, Decodable {
let repositories: [String]
}
extension RegistryClient {
/// List repositories in the registry.
///
/// Implements GET /v2/_catalog from the OCI Distribution Spec with pagination.
/// When prefix is provided, pagination skips ahead to the relevant portion of
/// the lexically-sorted catalog and stops once results move past the prefix.
///
/// - Parameter prefix: Optional prefix to filter repository names. Must be at least
/// two characters long to enable the skip-ahead optimization; shorter values are
/// treated as no prefix.
/// - Returns: An array of repository names matching the prefix (or all repositories
/// if no prefix is given).
public func catalog(prefix: String? = nil) async throws -> [String] {
let effectivePrefix = prefix.flatMap { $0.count >= 2 ? $0 : nil }
var allRepos: [String] = []
// When a prefix is provided, skip ahead in the lexically-sorted catalog
// by setting last to one position before the prefix. The OCI spec
// returns entries that sort after last, so dropping the last character
// of the prefix positions the cursor just before matching entries.
var last: String? = effectivePrefix.map { String($0.dropLast()) }
let pageSize = 100
while true {
var components = base
components.path = "/v2/_catalog"
var queryItems = [URLQueryItem(name: "n", value: String(pageSize))]
if let last {
queryItems.append(URLQueryItem(name: "last", value: last))
}
components.queryItems = queryItems
let repos: [String] = try await request(components: components) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
let buffer = try await response.body.collect(upTo: self.bufferSize)
return try JSONDecoder().decode(CatalogResponse.self, from: buffer).repositories
}
if let effectivePrefix {
let matching = repos.filter { $0.hasPrefix(effectivePrefix) }
allRepos.append(contentsOf: matching)
if let lastRepo = repos.last, !lastRepo.hasPrefix(effectivePrefix) && lastRepo > effectivePrefix {
break
}
} else {
allRepos.append(contentsOf: repos)
}
if repos.count < pageSize { break }
last = repos.last
}
return allRepos
}
}
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import Foundation
import NIOHTTP1
extension RegistryClient {
/// `RegistryClient` errors.
public enum Error: Swift.Error, CustomStringConvertible {
case invalidStatus(url: String, HTTPResponseStatus, reason: String? = nil)
/// Description of the errors.
public var description: String {
switch self {
case .invalidStatus(let u, let response, let reason):
return "HTTP request to \(u) failed with response: \(response.description). Reason: \(reason ?? "Unknown")"
}
}
}
/// The container registry typically returns actionable failure reasons in the response body
/// of the failing HTTP Request. This type models the structure of the error message.
/// Reference: https://distribution.github.io/distribution/spec/api/#errors
internal struct ErrorResponse: Codable {
let errors: [RemoteError]
internal struct RemoteError: Codable {
let code: String
let message: String
let detail: String?
}
internal static func fromResponseBody(_ body: HTTPClientResponse.Body) async -> ErrorResponse? {
guard var buffer = try? await body.collect(upTo: Int(1.mib())) else {
return nil
}
guard let bytes = buffer.readBytes(length: buffer.readableBytes) else {
return nil
}
let data = Data(bytes)
guard let jsonError = try? JSONDecoder().decode(ErrorResponse.self, from: data) else {
return nil
}
return jsonError
}
public var jsonString: String {
let data = try? JSONEncoder().encode(self)
guard let data else {
return "{}"
}
return String(data: data, encoding: .utf8) ?? "{}"
}
}
}
@@ -0,0 +1,237 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import ContainerizationError
import ContainerizationExtras
import Crypto
import Foundation
import NIOFoundationCompat
#if os(macOS)
import _NIOFileSystem
#endif
extension RegistryClient {
/// Resolve sends a HEAD request to the registry to find root manifest descriptor.
/// This descriptor serves as an entry point to retrieve resources from the registry.
public func resolve(name: String, tag: String) async throws -> Descriptor {
var components = base
// Make HEAD request to retrieve the digest header
components.path = "/v2/\(name)/manifests/\(tag)"
// The client should include an Accept header indicating which manifest content types it supports.
let mediaTypes = [
MediaTypes.dockerManifest,
MediaTypes.dockerManifestList,
MediaTypes.imageManifest,
MediaTypes.index,
"*/*",
]
let headers = [
("Accept", mediaTypes.joined(separator: ", "))
]
return try await request(components: components, method: .HEAD, headers: headers) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
guard let digest = response.headers.first(name: "Docker-Content-Digest") else {
throw ContainerizationError(.invalidArgument, message: "missing required header Docker-Content-Digest")
}
guard let type = response.headers.first(name: "Content-Type") else {
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Type")
}
guard let sizeStr = response.headers.first(name: "Content-Length") else {
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Length")
}
guard let size = Int64(sizeStr) else {
throw ContainerizationError(.invalidArgument, message: "cannot convert \(sizeStr) to Int64")
}
return Descriptor(mediaType: type, digest: digest, size: size)
}
}
/// Fetch resource (either manifest or blob) to memory with JSON decoding.
public func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
var components = base
let manifestTypes = [
MediaTypes.dockerManifest,
MediaTypes.dockerManifestList,
MediaTypes.imageManifest,
MediaTypes.index,
]
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
let resource = isManifest ? "manifests" : "blobs"
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
}
let headers = [
("Accept", mediaType)
]
return try await requestJSON(components: components, headers: headers)
}
/// Fetch resource (either manifest or blob) to memory as raw `Data`.
public func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
var components = base
let manifestTypes = [
MediaTypes.dockerManifest,
MediaTypes.dockerManifestList,
MediaTypes.imageManifest,
MediaTypes.index,
]
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
let resource = isManifest ? "manifests" : "blobs"
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
}
let headers = [
("Accept", mediaType)
]
return try await requestData(components: components, headers: headers)
}
/// Fetch a blob from remote registry.
/// This method is suitable for streaming data.
public func fetchBlob(
name: String,
descriptor: Descriptor,
closure: (Int64, HTTPClientResponse.Body) async throws -> Void
) async throws {
var components = base
components.path = "/v2/\(name)/blobs/\(descriptor.digest)"
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
}
let headers = [
("Accept", mediaType)
]
try await request(components: components, headers: headers) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
// How many bytes to expect
guard let expectedBytes = response.headers.first(name: "Content-Length").flatMap(Int64.init) else {
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Length")
}
try await closure(expectedBytes, response.body)
}
}
#if os(macOS)
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
var hasher = SHA256()
var received: Int64 = 0
let fs = _NIOFileSystem.FileSystem.shared
let handle = try await fs.openFile(forWritingAt: FilePath(file.absolutePath()), options: .newFile(replaceExisting: true))
var writer = handle.bufferedWriter()
do {
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
var itr = body.makeAsyncIterator()
while let buf = try await itr.next() {
let readBytes = Int64(buf.readableBytes)
received += readBytes
let written = try await writer.write(contentsOf: buf)
await progress?([
.addSize(written)
])
guard written == readBytes else {
throw ContainerizationError(
.internalError,
message: "could not write \(readBytes) bytes to file \(file)"
)
}
hasher.update(data: buf.readableBytesView)
}
}
try await writer.flush()
try await handle.close()
} catch {
do {
try await handle.close()
} catch {
// Use `detachUnsafeFileDescriptor()` as suggested by the error message to prevent a leak detection crash when `close()` fails.
_ = try handle.detachUnsafeFileDescriptor()
}
throw error
}
let computedDigest = hasher.finalize()
return (received, computedDigest)
}
#else
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
var hasher = SHA256()
var received: Int64 = 0
guard FileManager.default.createFile(atPath: file.path, contents: nil) else {
throw ContainerizationError(.internalError, message: "cannot create file at path \(file.path)")
}
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
let fd = try FileHandle(forWritingTo: file)
defer {
try? fd.close()
}
var itr = body.makeAsyncIterator()
while let buf = try await itr.next() {
let readBytes = Int64(buf.readableBytes)
received += readBytes
await progress?([
.addSize(readBytes)
])
try fd.write(contentsOf: buf.readableBytesView)
hasher.update(data: buf.readableBytesView)
}
}
let computedDigest = hasher.finalize()
return (received, computedDigest)
}
#endif
}
@@ -0,0 +1,181 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import ContainerizationError
import ContainerizationExtras
import Foundation
import NIO
extension RegistryClient {
/// Pushes the content specified by a descriptor to a remote registry.
/// - Parameters:
/// - name: The namespace which the descriptor should belong under.
/// - tag: The tag or digest for uniquely identifying the manifest.
/// By convention, any portion that may be a partial or whole digest
/// will be proceeded by an `@`. Anything preceding the `@` will be referred
/// to as "tag".
/// This is usually broken down into the following possibilities:
/// 1. <tag>
/// 2. <tag>@<digest>
/// 3. @<digest>
/// The tag is anything except `@` and `:`, and digest is anything after the `@`
/// - descriptor: The OCI descriptor of the content to be pushed.
/// - streamGenerator: A closure that produces an`AsyncStream` of `ByteBuffer`
/// for streaming data to the `HTTPClientRequest.Body`.
/// The caller is responsible for providing the `AsyncStream` where the data may come from
/// a file on disk, data in memory, etc.
/// - progress: The progress handler to invoke as data is sent.
public func push<T: Sendable & AsyncSequence>(
name: String,
ref tag: String,
descriptor: Descriptor,
streamGenerator: () throws -> T,
progress: ProgressHandler?
) async throws where T.Element == ByteBuffer {
var components = base
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
}
var isManifest = false
var existCheck: [String] = []
switch mediaType {
case MediaTypes.dockerManifest, MediaTypes.dockerManifestList, MediaTypes.imageManifest, MediaTypes.index:
isManifest = true
existCheck = self.getManifestPath(tag: tag, digest: descriptor.digest)
default:
existCheck = ["blobs", descriptor.digest]
}
// Check if the content already exists.
components.path = "/v2/\(name)/\(existCheck.joined(separator: "/"))"
let mediaTypes = [
mediaType,
"*/*",
]
var headers = [
("Accept", mediaTypes.joined(separator: ", "))
]
try await request(components: components, method: .HEAD, headers: headers) { response in
if response.status == .ok {
var exists = false
if isManifest && existCheck[1] != descriptor.digest {
if descriptor.digest == response.headers.first(name: "Docker-Content-Digest") {
exists = true
}
} else {
exists = true
}
if exists {
throw ContainerizationError(.exists, message: "content already exists \(descriptor.digest)")
}
} else if response.status != .notFound {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
}
if isManifest {
let path = self.getManifestPath(tag: tag, digest: descriptor.digest)
components.path = "/v2/\(name)/\(path.joined(separator: "/"))"
headers = [
("Content-Type", mediaType)
]
} else {
// Start upload request for blobs.
components.path = "/v2/\(name)/blobs/uploads/"
try await request(components: components, method: .POST) { response in
switch response.status {
case .ok, .accepted, .noContent:
break
case .created:
throw ContainerizationError(.exists, message: "content already exists \(descriptor.digest)")
default:
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
// Get the location to upload the blob.
guard let location = response.headers.first(name: "Location") else {
throw ContainerizationError(.invalidArgument, message: "missing required header Location")
}
guard let urlComponents = URLComponents(string: location) else {
throw ContainerizationError(.invalidArgument, message: "invalid url \(location)")
}
var queryItems = urlComponents.queryItems ?? []
queryItems.append(URLQueryItem(name: "digest", value: descriptor.digest))
components.path = urlComponents.path
components.queryItems = queryItems
headers = [
("Content-Type", "application/octet-stream"),
("Content-Length", String(descriptor.size)),
]
}
}
// We have to pass a body closure rather than a body to reset the stream when retrying.
let bodyClosure = {
let stream = try streamGenerator()
let body = HTTPClientRequest.Body.stream(stream, length: .known(descriptor.size))
return body
}
return try await request(components: components, method: .PUT, bodyClosure: bodyClosure, headers: headers) { response in
switch response.status {
case .ok, .created, .noContent:
break
default:
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
guard descriptor.digest == response.headers.first(name: "Docker-Content-Digest") else {
let required = response.headers.first(name: "Docker-Content-Digest") ?? ""
throw ContainerizationError(.internalError, message: "digest mismatch \(descriptor.digest) != \(required)")
}
}
}
private func getManifestPath(tag: String, digest: String) -> [String] {
var object = tag
if let i = tag.firstIndex(of: "@") {
let index = tag.index(after: i)
if String(tag[index...]) != digest {
object = ""
} else {
object = String(tag[...i])
}
}
if object == "" {
return ["manifests", digest]
}
return ["manifests", object]
}
}
@@ -0,0 +1,91 @@
//===----------------------------------------------------------------------===//
// 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 AsyncHTTPClient
import ContainerizationError
import Foundation
import NIOFoundationCompat
extension RegistryClient {
/// Query the OCI referrers API for artifacts that reference a given manifest digest.
///
/// Implements `GET /v2/{name}/referrers/{digest}` from the OCI Distribution Spec v1.1.
/// Falls back to the referrers tag schema when the API is not available (404).
///
/// - Parameters:
/// - name: The repository name (e.g., "library/ubuntu").
/// - digest: The digest of the subject manifest (e.g., "sha256:abc123...").
/// - artifactType: Optional filter to return only referrers with a matching artifactType.
/// - Returns: An `Index` whose `manifests` array contains descriptors of referring artifacts.
/// Returns an empty index if the registry does not support the referrers API
/// and no tag schema fallback is available.
public func referrers(name: String, digest: String, artifactType: String? = nil) async throws -> Index {
var components = base
components.path = "/v2/\(name)/referrers/\(digest)"
if let artifactType {
components.queryItems = [URLQueryItem(name: "artifactType", value: artifactType)]
}
let headers = [("Accept", MediaTypes.index)]
let result: Index = try await request(components: components, method: .GET, headers: headers) { response in
if response.status == .notFound {
return await self.referrersTagFallback(name: name, digest: digest, artifactType: artifactType)
}
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
let buffer = try await response.body.collect(upTo: self.bufferSize)
return try JSONDecoder().decode(Index.self, from: buffer)
}
return result
}
/// Fallback for registries that don't support the referrers API.
///
/// Uses the OCI referrers tag schema: referrers for a digest are stored as an
/// index at the tag `<algorithm>-<hex>` (e.g., `sha256-abc123...`).
private func referrersTagFallback(name: String, digest: String, artifactType: String? = nil) async -> Index {
let referrerTag = digest.replacingOccurrences(of: ":", with: "-")
let descriptor: Descriptor
do {
descriptor = try await resolve(name: name, tag: referrerTag)
} catch {
return Index(schemaVersion: 2, manifests: [])
}
let index: Index
do {
index = try await fetch(name: name, descriptor: descriptor)
} catch {
return Index(schemaVersion: 2, manifests: [])
}
guard let artifactType else {
return index
}
let filtered = index.manifests.filter { $0.artifactType == artifactType }
return Index(schemaVersion: 2, manifests: filtered)
}
}
@@ -0,0 +1,210 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import ContainerizationError
import Foundation
struct TokenRequest {
public static let authenticateHeaderName = "WWW-Authenticate"
/// The credentials that will be used in the authentication header when fetching the token.
let authentication: Authentication?
/// The realm against which the token should be requested.
let realm: String
/// The name of the service which hosts the resource.
let service: String
/// Whether to return a refresh token along with the bearer token.
let offlineToken: Bool
/// String identifying the client.
let clientId: String
/// The resource in question, formatted as one of the space-delimited entries from the scope parameters from the WWW-Authenticate header shown above.
let scope: String?
init(
realm: String,
service: String,
clientId: String,
scope: String?,
offlineToken: Bool = false,
authentication: Authentication? = nil
) {
self.realm = realm
self.service = service
self.offlineToken = offlineToken
self.clientId = clientId
self.scope = scope
self.authentication = authentication
}
}
struct TokenResponse: Codable, Hashable {
/// An opaque Bearer token that clients should supply to subsequent requests in the Authorization header.
let token: String?
/// For compatibility with OAuth 2.0, we will also accept token under the name access_token.
/// At least one of these fields must be specified, but both may also appear (for compatibility with older clients).
/// When both are specified, they should be equivalent; if they differ the client's choice is undefined.
let accessToken: String?
/// The duration in seconds since the token was issued that it will remain valid.
/// When omitted, this defaults to 60 seconds.
let expiresIn: UInt?
/// The RFC3339-serialized UTC standard time at which a given token was issued.
/// If issued_at is omitted, the expiration is from when the token exchange completed.
let issuedAt: String?
/// Token which can be used to get additional access tokens for the same subject with different scopes.
/// This token should be kept secure by the client and only sent to the authorization server which issues bearer tokens.
/// This field will only be set when `offline_token=true` is provided in the request.
let refreshToken: String?
var scope: String?
private enum CodingKeys: String, CodingKey {
case token = "token"
case accessToken = "access_token"
case expiresIn = "expires_in"
case issuedAt = "issued_at"
case refreshToken = "refresh_token"
}
func getToken() -> String? {
if let t = token ?? accessToken {
return "Bearer \(t)"
}
return nil
}
func isValid(scope: String?) -> Bool {
guard let issuedAt else {
return false
}
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let issued = isoFormatter.date(from: issuedAt) else {
return false
}
let expiresIn = expiresIn ?? 0
let now = Date()
let elapsed = now.timeIntervalSince(issued)
guard elapsed < Double(expiresIn) else {
return false
}
if let requiredScope = scope {
return requiredScope == self.scope
}
return false
}
}
struct AuthenticateChallenge: Equatable {
let type: String
let realm: String?
let service: String?
let scope: String?
let error: String?
init(type: String, realm: String?, service: String?, scope: String?, error: String?) {
self.type = type
self.realm = realm
self.service = service
self.scope = scope
self.error = error
}
init(type: String, values: [String: String]) {
self.type = type
self.realm = values["realm"]
self.service = values["service"]
self.scope = values["scope"]
self.error = values["error"]
}
}
extension RegistryClient {
/// Fetch an auto token for all subsequent HTTP requests
/// See https://docs.docker.com/registry/spec/auth/token/
internal func fetchToken(request: TokenRequest) async throws -> TokenResponse {
guard var components = URLComponents(string: request.realm) else {
throw ContainerizationError(.invalidArgument, message: "cannot create URL from \(request.realm)")
}
components.queryItems = [
URLQueryItem(name: "client_id", value: request.clientId),
URLQueryItem(name: "service", value: request.service),
]
var scope = ""
if let reqScope = request.scope {
scope = reqScope
components.queryItems?.append(URLQueryItem(name: "scope", value: reqScope))
}
if request.offlineToken {
components.queryItems?.append(URLQueryItem(name: "offline_token", value: "true"))
}
var response: TokenResponse = try await requestJSON(components: components, headers: [])
response.scope = scope
return response
}
internal func createTokenRequest(parsing authenticateHeaders: [String]) throws -> TokenRequest {
let parsedHeaders = Self.parseWWWAuthenticateHeaders(headers: authenticateHeaders)
let bearerChallenge = parsedHeaders.first { $0.type == "Bearer" }
guard let bearerChallenge else {
throw ContainerizationError(.invalidArgument, message: "missing Bearer challenge in \(TokenRequest.authenticateHeaderName) header")
}
guard let realm = bearerChallenge.realm else {
throw ContainerizationError(.invalidArgument, message: "cannot parse realm from \(TokenRequest.authenticateHeaderName) header")
}
guard let service = bearerChallenge.service else {
throw ContainerizationError(.invalidArgument, message: "cannot parse service from \(TokenRequest.authenticateHeaderName) header")
}
let scope = bearerChallenge.scope
let tokenRequest = TokenRequest(realm: realm, service: service, clientId: self.clientID, scope: scope, authentication: self.authentication)
return tokenRequest
}
internal static func parseWWWAuthenticateHeaders(headers: [String]) -> [AuthenticateChallenge] {
var parsed: [String: [String: String]] = [:]
for challenge in headers {
let trimmedChallenge = challenge.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmedChallenge.split(separator: " ", maxSplits: 1)
guard parts.count == 2 else {
continue
}
guard let scheme = parts.first else {
continue
}
var params: [String: String] = [:]
let header = String(parts[1])
let pattern = #"(\w+)="([^"]+)"#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let matches = regex.matches(in: header, options: [], range: NSRange(header.startIndex..., in: header))
for match in matches {
if let keyRange = Range(match.range(at: 1), in: header),
let valueRange = Range(match.range(at: 2), in: header)
{
let key = String(header[keyRange])
let value = String(header[valueRange])
params[key] = value
}
}
parsed[String(scheme)] = params
}
var parsedChallenges: [AuthenticateChallenge] = []
for (type, values) in parsed {
parsedChallenges.append(.init(type: type, values: values))
}
return parsedChallenges
}
}
@@ -0,0 +1,306 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 AsyncHTTPClient
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
import Logging
import NIO
import NIOHTTP1
import NIOSSL
#if os(macOS)
import Network
#endif
/// Data used to control retry behavior for `RegistryClient`.
public struct RetryOptions: Sendable {
/// The maximum number of retries to attempt before failing.
public var maxRetries: Int
/// The retry interval in nanoseconds.
public var retryInterval: UInt64
/// A provided closure to handle if a given HTTP response should be
/// retried.
public var shouldRetry: (@Sendable (HTTPClientResponse) -> Bool)?
public init(maxRetries: Int, retryInterval: UInt64, shouldRetry: (@Sendable (HTTPClientResponse) -> Bool)? = nil) {
self.maxRetries = maxRetries
self.retryInterval = retryInterval
self.shouldRetry = shouldRetry
}
}
/// A client for interacting with OCI compliant container registries.
public final class RegistryClient: ContentClient {
private static let defaultRetryOptions = RetryOptions(
maxRetries: 3,
retryInterval: 1_000_000_000,
shouldRetry: ({ response in
response.status.code >= 500
})
)
let client: HTTPClient
let proxyURL: URL?
let base: URLComponents
let clientID: String
let authentication: Authentication?
let retryOptions: RetryOptions?
let bufferSize: Int
public convenience init(
reference: String,
insecure: Bool = false,
auth: Authentication? = nil,
tlsConfiguration: TLSConfiguration? = nil,
logger: Logger? = nil,
) throws {
let ref = try Reference.parse(reference)
guard let domain = ref.resolvedDomain else {
throw ContainerizationError(.invalidArgument, message: "invalid domain for image reference \(reference)")
}
let scheme = insecure ? "http" : "https"
let _url = "\(scheme)://\(domain)"
guard let url = URL(string: _url) else {
throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL")
}
guard let host = url.host else {
throw ContainerizationError(.invalidArgument, message: "invalid host \(domain)")
}
let port = url.port
self.init(
host: host,
scheme: scheme,
port: port,
authentication: auth,
retryOptions: Self.defaultRetryOptions,
tlsConfiguration: tlsConfiguration,
)
}
public init(
host: String,
scheme: String? = "https",
port: Int? = nil,
authentication: Authentication? = nil,
clientID: String? = nil,
retryOptions: RetryOptions? = nil,
bufferSize: Int = Int(4.mib()),
tlsConfiguration: TLSConfiguration? = nil,
logger: Logger? = nil,
) {
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = port
self.base = components
self.clientID = clientID ?? "containerization-registry-client"
self.authentication = authentication
self.retryOptions = retryOptions
self.bufferSize = bufferSize
var httpConfiguration = HTTPClient.Configuration()
// proxy configuration assumes all client requests will go to `base` URL
self.proxyURL = ProxyUtils.proxyFromEnvironment(scheme: scheme, host: host)
if let proxyURL = self.proxyURL, let proxyHost = proxyURL.host {
let proxyPort = proxyURL.port ?? (proxyURL.scheme == "https" ? 443 : 80)
httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyPort)
}
if tlsConfiguration != nil {
httpConfiguration.tlsConfiguration = tlsConfiguration
}
if let logger {
self.client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration, backgroundActivityLogger: logger)
} else {
self.client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
}
}
deinit {
_ = client.shutdown()
}
func host() -> String {
base.host ?? ""
}
internal func request<T>(
components: URLComponents,
method: HTTPMethod = .GET,
bodyClosure: () throws -> HTTPClientRequest.Body? = { nil },
headers: [(String, String)]? = nil,
closure: (HTTPClientResponse) async throws -> T
) async throws -> T {
guard let path = components.url?.absoluteString else {
throw ContainerizationError(.invalidArgument, message: "invalid url \(components.path)")
}
var request = HTTPClientRequest(url: path)
request.method = method
var currentToken: TokenResponse?
let token: String? = try await {
if let basicAuth = authentication {
return try await basicAuth.token()
}
return nil
}()
if let token {
request.headers.add(name: "Authorization", value: "\(token)")
}
// Add any arbitrary headers
headers?.forEach { (k, v) in request.headers.add(name: k, value: v) }
var retryCount = 0
var response: HTTPClientResponse?
while true {
request.body = try bodyClosure()
do {
let _response = try await client.execute(request, deadline: .distantFuture)
response = _response
if _response.status == .unauthorized || _response.status == .forbidden {
let authHeader = _response.headers[TokenRequest.authenticateHeaderName]
let tokenRequest: TokenRequest
do {
tokenRequest = try self.createTokenRequest(parsing: authHeader)
} catch {
// The server did not tell us how to authenticate our requests,
// Or we do not support scheme the server is requesting for.
// Throw the 401/403 to the caller, and let them decide how to proceed.
throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: String(describing: error))
}
if let ct = currentToken, ct.isValid(scope: tokenRequest.scope) {
break
}
do {
let _currentToken = try await fetchToken(request: tokenRequest)
guard let token = _currentToken.getToken() else {
throw ContainerizationError(.internalError, message: "failed to fetch Bearer token")
}
currentToken = _currentToken
request.headers.replaceOrAdd(name: "Authorization", value: token)
retryCount += 1
} catch let err as RegistryClient.Error {
guard case .invalidStatus(_, let status, _) = err else {
throw err
}
if status == .unauthorized || status == .forbidden {
throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: "access denied or wrong credentials")
}
throw err
}
continue
} else if _response.status == .badRequest && request.headers.contains(name: "Authorization") {
// Retry without basic auth
request.headers.remove(name: "Authorization")
retryCount += 1
continue
}
guard let retryOptions = self.retryOptions else {
break
}
guard retryCount < retryOptions.maxRetries else {
break
}
guard let shouldRetry = retryOptions.shouldRetry, shouldRetry(_response) else {
break
}
retryCount += 1
try await Task.sleep(nanoseconds: retryOptions.retryInterval)
continue
} catch let err as RegistryClient.Error {
throw err
} catch {
#if os(macOS)
if let err = error as? NWError {
if err.errorCode == kDNSServiceErr_NoSuchRecord {
let message: String
if let proxyURL = self.proxyURL, let proxyHost = proxyURL.host {
message = "failed to resolve either repository hostname \(host()) or proxy hostname \(proxyHost)"
} else {
message = "failed to resolve either repository hostname \(host())"
}
throw ContainerizationError(.internalError, message: message)
}
}
#endif
guard let retryOptions = self.retryOptions, retryCount < retryOptions.maxRetries else {
throw error
}
retryCount += 1
try await Task.sleep(nanoseconds: retryOptions.retryInterval)
}
}
guard let response else {
throw ContainerizationError(.internalError, message: "invalid response")
}
return try await closure(response)
}
internal func requestData(
components: URLComponents,
headers: [(String, String)]? = nil
) async throws -> Data {
let bytes: ByteBuffer = try await requestBuffer(components: components, headers: headers)
return Data(buffer: bytes)
}
internal func requestBuffer(
components: URLComponents,
headers: [(String, String)]? = nil
) async throws -> ByteBuffer {
try await request(components: components, method: .GET, headers: headers) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
return try await response.body.collect(upTo: self.bufferSize)
}
}
internal func requestJSON<T: Decodable>(
components: URLComponents,
headers: [(String, String)]? = nil
) async throws -> T {
let buffer = try await self.requestBuffer(components: components, headers: headers)
return try JSONDecoder().decode(T.self, from: buffer)
}
/// A minimal endpoint, mounted at /v2/ will provide version support information based on its response statuses.
/// See https://distribution.github.io/distribution/spec/api/#api-version-check
public func ping() async throws {
var components = base
components.path = "/v2/"
try await request(components: components) { response in
guard response.status == .ok else {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
}
}
}