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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user