chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

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,24 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
/// AnnotationKeys contains a subset of "dictionary keys" for commonly used annotations in an OCI Image Descriptor
/// https://github.com/opencontainers/image-spec/blob/main/annotations.md
public struct AnnotationKeys: Codable, Sendable {
public static let containerizationIndexIndirect = "com.apple.containerization.index.indirect"
public static let containerizationImageName = "com.apple.containerization.image.name"
public static let containerdImageName = "io.containerd.image.name"
public static let openContainersImageName = "org.opencontainers.image.ref.name"
}
+146
View File
@@ -0,0 +1,146 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
#if canImport(Musl)
import Musl
private let _mount = Musl.mount
private let _umount = Musl.umount2
#elseif canImport(Glibc)
import Glibc
private let _mount = Glibc.mount
private let _umount = Glibc.umount2
#endif
/// `Bundle` represents an OCI runtime spec bundle for running
/// a container.
public struct Bundle: Sendable {
/// The path to the bundle.
public let path: URL
/// The path to the OCI runtime spec config.json file.
public var configPath: URL {
self.path.appending(path: "config.json")
}
/// The path to a rootfs mount inside the bundle.
public var rootfsPath: URL {
self.path.appending(path: "rootfs")
}
/// Create the OCI bundle.
///
/// - Parameters:
/// - path: A URL pointing to where to create the bundle on the filesystem.
/// - spec: A data blob that should contain an OCI runtime spec. This will be written
/// to the bundle as a "config.json" file.
public static func create(path: URL, spec: Data) throws -> Bundle {
try self.init(path: path, spec: spec)
}
/// Create the OCI bundle.
///
/// - Parameters:
/// - path: A URL pointing to where to create the bundle on the filesystem.
/// - spec: An OCI runtime spec that will be written to the bundle as a "config.json"
/// file.
public static func create(path: URL, spec: ContainerizationOCI.Spec) throws -> Bundle {
try self.init(path: path, spec: spec)
}
/// Load an OCI bundle from the provided path.
///
/// - Parameters:
/// - path: A URL pointing to where to load the bundle from on the filesystem.
public static func load(path: URL) throws -> Bundle {
try self.init(path: path)
}
private init(path: URL) throws {
let fm = FileManager.default
if !fm.fileExists(atPath: path.path) {
throw ContainerizationError(.invalidArgument, message: "no bundle at \(path.path)")
}
self.path = path
}
// This constructor does not do any validation that data is actually a
// valid OCI spec.
private init(path: URL, spec: Data) throws {
self.path = path
let fm = FileManager.default
try fm.createDirectory(
atPath: self.path.appending(component: "rootfs").path,
withIntermediateDirectories: true
)
try spec.write(to: self.configPath)
}
private init(path: URL, spec: ContainerizationOCI.Spec) throws {
self.path = path
let fm = FileManager.default
try fm.createDirectory(
atPath: self.path.appending(component: "rootfs").path,
withIntermediateDirectories: true
)
let specData = try JSONEncoder().encode(spec)
try specData.write(to: self.configPath)
}
/// Delete the OCI bundle from the filesystem.
public func delete() throws {
// Unmount, and then blow away the dir.
#if os(Linux)
let rootfs = self.rootfsPath
if Self.isMountpoint(rootfs) {
guard _umount(rootfs.path, 0) == 0 else {
throw POSIXError.fromErrno()
}
}
#endif
// removeItem is recursive so should blow away the rootfs dir inside as well.
let fm = FileManager.default
try fm.removeItem(at: self.path)
}
/// Load and return the OCI runtime spec written to the bundle.
public func loadConfig() throws -> ContainerizationOCI.Spec {
let data = try Data(contentsOf: self.configPath)
return try JSONDecoder().decode(ContainerizationOCI.Spec.self, from: data)
}
private static func isMountpoint(_ path: URL) -> Bool {
var st = stat()
var parent_st = stat()
guard stat(path.path, &st) == 0 else {
return false
}
let parentPath = path.deletingLastPathComponent()
guard stat(parentPath.path, &parent_st) == 0 else {
return false
}
return st.st_dev != parent_st.st_dev
}
}
@@ -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)
}
}
}
}
@@ -0,0 +1,56 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
package actor AsyncStore<T> {
private var _value: T?
package init(_ value: T? = nil) {
self._value = value
}
package func get() -> T? {
self._value
}
package func set(_ value: T) {
self._value = value
}
}
package actor AsyncSet<T: Hashable> {
private var buffer: Set<T>
package init<S: Sequence>(_ elements: S) where S.Element == T {
buffer = Set(elements)
}
package var count: Int {
buffer.count
}
package func insert(_ element: T) {
buffer.insert(element)
}
@discardableResult
package func remove(_ element: T) -> T? {
buffer.remove(element)
}
package func contains(_ element: T) -> Bool {
buffer.contains(element)
}
}
@@ -0,0 +1,59 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationExtras
import Crypto
import Foundation
import NIOCore
/// Protocol for defining a single OCI content
public protocol Content: Sendable {
/// URL to the content
var path: URL { get }
/// sha256 of content
func digest() throws -> SHA256.Digest
/// Size of content
func size() throws -> UInt64
/// Data representation of entire content
func data() throws -> Data
/// Data representation partial content
func data(offset: UInt64, length: Int) throws -> Data?
/// Decode the content into an object
func decode<T>() throws -> T where T: Decodable
}
/// Protocol defining methods to fetch and push OCI content
public protocol ContentClient: Sendable {
func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T
func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest)
func fetchData(name: String, descriptor: Descriptor) async throws -> Data
func push<T: Sendable & AsyncSequence>(
name: String,
ref: String,
descriptor: Descriptor,
streamGenerator: () throws -> T,
progress: ProgressHandler?
) async throws where T.Element == ByteBuffer
}
@@ -0,0 +1,67 @@
//===----------------------------------------------------------------------===//
// 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 Crypto
import Foundation
/// Protocol for defining a content store where OCI image metadata and layers will be managed
/// and manipulated.
public protocol ContentStore: Sendable {
/// Retrieves a piece of Content based on the digest string.
/// Returns `nil` if the requested digest is not found.
func get(digest: String) async throws -> Content?
/// Retrieves a specific content metadata type based on the digest string.
/// Returns `nil` if the requested digest is not found.
func get<T: Decodable>(digest: String) async throws -> T?
/// Remove a list of digests in the content store.
@discardableResult
func delete(digests: [String]) async throws -> ([String], UInt64)
/// Removes all content from the store except for the digests in the provided list.
@discardableResult
func delete(keeping: [String]) async throws -> ([String], UInt64)
/// Creates a transactional write to the content store.
/// The function takes a closure given a temporary `URL` of the base directory which all contents should be written to.
/// This is transaction write where any failed operation in the closure (caught exception) will result in all contents written
/// in the closure to be deleted.
///
/// If the closure succeeds, then all the content that have been written to the temporary `URL` will be moved into the actual
/// blobs path of the content store.
@discardableResult
func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String]
/// Creates a new ingest session and returns the session ID and temporary ingest directory corresponding to the session.
/// The contents from the ingest directory are processed and moved into the content store once the session is marked complete.
/// This can be done by invoking the `completeIngestSession` method with the returned session ID.
func newIngestSession() async throws -> (id: String, ingestDir: URL)
/// Completes a previously started ingest session corresponding to `id`.
/// The contents from the ingest directory from the session are moved into the content store atomically.
/// Any failure encountered will result in a transaction failure causing none of the contents to be ingested into the store.
@discardableResult
func completeIngestSession(_ id: String) async throws -> [String]
/// Cancels a previously started ingest session corresponding to `id`.
/// The contents from the ingest directory corresponding to the session are removed.
func cancelIngestSession(_ id: String) async throws
/// Total bytes allocated on disk for the content store, covering
/// committed blobs and any active ingest sessions.
func totalAllocatedSize() async throws -> UInt64
}
@@ -0,0 +1,137 @@
//===----------------------------------------------------------------------===//
// 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 Crypto
import Foundation
import NIOCore
/// Provides a context to write data into a directory.
public class ContentWriter {
private let base: URL
private let encoder = JSONEncoder()
/// Create a new ContentWriter.
/// - Parameters:
/// - base: The URL to write content to. If this is not a directory a
/// ContainerizationError will be thrown with a code of .internalError.
public init(for base: URL) throws {
self.encoder.outputFormatting = [JSONEncoder.OutputFormatting.sortedKeys]
self.base = base
var isDirectory = ObjCBool(true)
let exists = FileManager.default.fileExists(atPath: base.path, isDirectory: &isDirectory)
guard exists && isDirectory.boolValue else {
throw ContainerizationError(.internalError, message: "cannot create ContentWriter for path \(base.absolutePath()), not a directory")
}
}
/// Writes the data blob to the base URL provided in the constructor.
/// - Parameters:
/// - data: The data blob to write to a file under the base path.
@discardableResult
public func write(_ data: Data) throws -> (size: Int64, digest: SHA256.Digest) {
let digest = SHA256.hash(data: data)
let destination = base.appendingPathComponent(digest.encoded)
try data.write(to: destination)
return (Int64(data.count), digest)
}
/// Reads the data present in the passed in URL and writes it to the base path.
/// - Parameters:
/// - url: The URL to read the data from.
@discardableResult
public func create(from url: URL) throws -> (size: Int64, digest: SHA256.Digest) {
let sourceFD = Foundation.open(url.path, O_RDONLY)
guard sourceFD >= 0 else {
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
let err = POSIXError(errCode)
throw ContainerizationError(.internalError, message: "failed to open \(url.path) for reading", cause: err)
}
defer { close(sourceFD) }
let tempURL = base.appendingPathComponent(UUID().uuidString)
let destFD = Foundation.open(tempURL.path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
guard destFD >= 0 else {
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
let err = POSIXError(errCode)
throw ContainerizationError(.internalError, message: "failed to create temporary file at \(tempURL.absolutePath())", cause: err)
}
let chunkSize = 1024 * 1024 // 1 MiB
let buf = UnsafeMutableRawBufferPointer.allocate(byteCount: chunkSize, alignment: 1)
defer { buf.deallocate() }
guard let baseAddress = buf.baseAddress else {
close(destFD)
try? FileManager.default.removeItem(at: tempURL)
throw ContainerizationError(.internalError, message: "failed to allocate read buffer of size \(chunkSize)")
}
var hasher = SHA256()
var totalSize: Int64 = 0
while true {
let n = read(sourceFD, baseAddress, chunkSize)
if n == 0 { break }
if n < 0 {
close(destFD)
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
let err = POSIXError(errCode)
try? FileManager.default.removeItem(at: tempURL)
throw ContainerizationError(.internalError, message: "failed to read from \(url.path)", cause: err)
}
hasher.update(data: UnsafeRawBufferPointer(start: baseAddress, count: n))
var written = 0
while written < n {
let w = Foundation.write(destFD, baseAddress.advanced(by: written), n - written)
if w < 0 {
close(destFD)
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
let err = POSIXError(errCode)
try? FileManager.default.removeItem(at: tempURL)
throw ContainerizationError(.internalError, message: "failed to write to \(tempURL.absolutePath())", cause: err)
}
written += w
}
totalSize += Int64(n)
}
close(destFD)
let digest = hasher.finalize()
let destination = base.appendingPathComponent(digest.encoded)
do {
try FileManager.default.moveItem(at: tempURL, to: destination)
} catch let error as NSError {
guard error.code == NSFileWriteFileExistsError else {
throw error
}
try? FileManager.default.removeItem(at: tempURL)
} catch {
try? FileManager.default.removeItem(at: tempURL)
throw error
}
return (totalSize, digest)
}
/// Encodes the passed in type as a JSON blob and writes it to the base path.
/// - Parameters:
/// - content: The type to convert to JSON.
@discardableResult
public func create<T: Encodable>(from content: T) throws -> (size: Int64, digest: SHA256.Digest) {
let data = try self.encoder.encode(content)
return try self.write(data)
}
}
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// 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 Crypto
import Foundation
public final class LocalContent: Content {
public let path: URL
private let file: FileHandle
public init(path: URL) throws {
guard FileManager.default.fileExists(atPath: path.path) else {
throw ContainerizationError(.notFound, message: "content at path \(path.absolutePath())")
}
self.file = try FileHandle(forReadingFrom: path)
self.path = path
}
public func digest() throws -> SHA256.Digest {
let bufferSize = 64 * 1024 // 64 KB
var hasher = SHA256()
try self.file.seek(toOffset: 0)
while case let data = file.readData(ofLength: bufferSize), !data.isEmpty {
hasher.update(data: data)
}
let digest = hasher.finalize()
try self.file.seek(toOffset: 0)
return digest
}
public func data(offset: UInt64 = 0, length size: Int = 0) throws -> Data? {
try file.seek(toOffset: offset)
if size == 0 {
return try file.readToEnd()
}
return try file.read(upToCount: size)
}
public func data() throws -> Data {
try Data(contentsOf: self.path)
}
public func size() throws -> UInt64 {
let fileAttrs = try FileManager.default.attributesOfItem(atPath: self.path.absolutePath())
if let size = fileAttrs[FileAttributeKey.size] as? UInt64 {
return size
}
throw ContainerizationError(.internalError, message: "could not determine file size for \(path.absolutePath())")
}
public func decode<T>() throws -> T where T: Decodable {
let json = JSONDecoder()
let data = try Data(contentsOf: self.path)
return try json.decode(T.self, from: data)
}
deinit {
try? self.file.close()
}
}
@@ -0,0 +1,230 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// swiftlint:disable unused_optional_binding
import ContainerizationError
import ContainerizationExtras
import Crypto
import Foundation
/// A `ContentStore` implementation that stores content on the local filesystem.
public actor LocalContentStore: ContentStore {
private static let encoder = JSONEncoder()
private let _basePath: URL
private let _ingestPath: URL
private let _blobPath: URL
private let _lock: AsyncLock
private var activeIngestSessions: AsyncSet<String> = AsyncSet([])
/// Create a new `LocalContentStore`.
///
/// - Parameters:
/// - path: The path where content should be written under.
public init(path: URL) throws {
let ingestPath = path.appendingPathComponent("ingest")
let blobPath = path.appendingPathComponent("blobs/sha256")
let fileManager = FileManager.default
try fileManager.createDirectory(at: ingestPath, withIntermediateDirectories: true)
try fileManager.createDirectory(at: blobPath, withIntermediateDirectories: true)
self._basePath = path
self._ingestPath = ingestPath
self._blobPath = blobPath
self._lock = AsyncLock()
Self.encoder.outputFormatting = .sortedKeys
}
/// Get a piece of content from the store. Returns nil if not
/// found.
///
/// - Parameters:
/// - digest: The string digest of the content.
public func get(digest: String) throws -> Content? {
let d = digest.trimmingDigestPrefix
let path = self._blobPath.appendingPathComponent(d)
do {
return try LocalContent(path: path)
} catch let err as ContainerizationError {
switch err.code {
case .notFound:
return nil
default:
throw err
}
}
}
/// Get a piece of content from the store and return the decoded version of
/// it.
///
/// - Parameters:
/// - digest: The string digest of the content.
public func get<T: Decodable & Sendable>(digest: String) throws -> T? {
guard let content: Content = try self.get(digest: digest) else {
return nil
}
return try content.decode()
}
/// Delete all content besides a set provided.
///
/// - Parameters:
/// - keeping: The set of string digests to keep.
public func delete(keeping: [String]) async throws -> ([String], UInt64) {
let fileManager = FileManager.default
let all = try fileManager.contentsOfDirectory(at: self._blobPath, includingPropertiesForKeys: nil)
let allDigests = Set(all.map { $0.lastPathComponent })
let toDelete = allDigests.subtracting(keeping)
return try await self.delete(digests: Array(toDelete))
}
/// Delete a specific set of content.
///
/// - Parameters:
/// - digests: Array of strings denoting the digests of the content to delete.
@discardableResult
public func delete(digests: [String]) async throws -> ([String], UInt64) {
let store = AsyncStore<([String], UInt64)>()
try await self._lock.withLock { context in
let fileManager = FileManager.default
var deleted: [String] = []
var deletedBytes: UInt64 = 0
for toDelete in digests {
let p = self._blobPath.appendingPathComponent(toDelete)
guard let content = try? LocalContent(path: p) else {
continue
}
deletedBytes += try content.size()
try fileManager.removeItem(at: p)
deleted.append(toDelete)
}
await store.set((deleted, deletedBytes))
}
return await store.get() ?? ([], 0)
}
/// Creates a transactional write to the content store.
///
/// - Parameters:
/// - body: Closure that is given a temporary `URL` of the base directory which all contents should be written to.
/// This is a transaction write where any failed operation in the closure (caught exception) will result in all contents written
/// in the closure to be deleted. If the closure succeeds, then all the content that have been written to the temporary `URL`
/// will be moved into the actual blobs path of the content store.
@discardableResult
public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] {
let (id, tempPath) = try await self.newIngestSession()
try await body(tempPath)
return try await self.completeIngestSession(id)
}
/// Creates a new ingest session and returns the session ID and temporary ingest directory corresponding to the session.
/// The contents from the ingest directory are processed and moved into the content store once the session is marked complete.
/// This can be done by invoking the `completeIngestSession` method with the returned session ID.
public func newIngestSession() async throws -> (id: String, ingestDir: URL) {
let id = UUID().uuidString
let temporaryPath = self._ingestPath.appendingPathComponent(id)
let fileManager = FileManager.default
try fileManager.createDirectory(atPath: temporaryPath.path, withIntermediateDirectories: true)
await self.activeIngestSessions.insert(id)
return (id, temporaryPath)
}
/// Completes a previously started ingest session corresponding to `id`. The contents from the ingest
/// directory from the session are moved into the content store atomically. Any failure encountered will
/// result in a transaction failure causing none of the contents to be ingested into the store.
/// - Parameters:
/// - id: id of the ingest session to complete.
@discardableResult
public func completeIngestSession(_ id: String) async throws -> [String] {
guard await activeIngestSessions.contains(id) else {
throw ContainerizationError(.internalError, message: "invalid session id \(id)")
}
await activeIngestSessions.remove(id)
let temporaryPath = self._ingestPath.appendingPathComponent(id)
let fileManager = FileManager.default
defer {
try? fileManager.removeItem(at: temporaryPath)
}
let tempDigests: [URL] = try fileManager.contentsOfDirectory(at: temporaryPath, includingPropertiesForKeys: nil)
return try await self._lock.withLock { context in
var moved: [String] = []
let fileManager = FileManager.default
do {
try tempDigests.forEach {
let digest = $0.lastPathComponent
let target = self._blobPath.appendingPathComponent(digest)
// only ingest if not exists
if !fileManager.fileExists(atPath: target.path) {
try fileManager.moveItem(at: $0, to: target)
moved.append(digest)
}
}
} catch {
moved.forEach {
try? fileManager.removeItem(at: self._blobPath.appendingPathComponent($0))
}
throw error
}
return tempDigests.map { $0.lastPathComponent }
}
}
/// Cancels a previously started ingest session corresponding to `id`.
/// The contents from the ingest directory corresponding to the session are removed.
/// - Parameters:
/// - id: id of the ingest session to complete.
public func cancelIngestSession(_ id: String) async throws {
guard let _ = await self.activeIngestSessions.remove(id) else {
return
}
let temporaryPath = self._ingestPath.appendingPathComponent(id)
let fileManager = FileManager.default
try? fileManager.removeItem(at: temporaryPath)
}
/// Total bytes allocated on disk for the content store, covering
/// committed blobs and any active ingest sessions.
public func totalAllocatedSize() throws -> UInt64 {
let fileManager = FileManager.default
guard
let enumerator = fileManager.enumerator(
at: self._basePath,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
)
else {
throw ContainerizationError(.internalError, message: "failed to enumerate content store at \(self._basePath.path)")
}
var size: UInt64 = 0
for case let fileURL as URL in enumerator {
guard let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey]), values.isRegularFile == true,
let fileSize = values.totalFileAllocatedSize
else {
// Skip directories and other non-regular entries. On Linux,
// `.totalFileAllocatedSizeKey` reports block allocation for
// directories, which would otherwise count empty-store
// inode overhead as content.
continue
}
size += UInt64(fileSize)
}
return size
}
}
@@ -0,0 +1,32 @@
//===----------------------------------------------------------------------===//
// 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 Crypto
import Foundation
extension SHA256.Digest {
/// Returns the digest as a string.
public var digestString: String {
let parts = self.description.split(separator: ": ")
return "sha256:\(parts[1])"
}
/// Returns the digest without a 'sha256:' prefix.
public var encoded: String {
let parts = self.description.split(separator: ": ")
return String(parts[1])
}
}
@@ -0,0 +1,26 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
extension String {
/// Removes any prefix (sha256:) from a digest string.
public var trimmingDigestPrefix: String {
let split = self.split(separator: ":")
if split.count == 2 {
return String(split[1])
}
return self
}
}
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// 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
extension URL {
/// Returns the unescaped absolutePath of a URL joined by separator.
public func absolutePath() -> String {
#if os(macOS)
return self.path(percentEncoded: false)
#else
return self.path
#endif
}
/// Returns the domain name of a registry.
public var domain: String? {
guard let host = self.absoluteString.split(separator: ":").first else {
return nil
}
return String(host)
}
}
@@ -0,0 +1,62 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/descriptor.go
import Foundation
/// Descriptor describes the disposition of targeted content.
/// This structure provides `application/vnd.oci.descriptor.v1+json` mediatype
/// when marshalled to JSON.
public struct Descriptor: Codable, Sendable, Equatable {
/// mediaType is the media type of the object this schema refers to.
public let mediaType: String
/// digest is the digest of the targeted content.
public let digest: String
/// size specifies the size in bytes of the blob.
public let size: Int64
/// urls specifies a list of URLs from which this object MAY be downloaded.
public let urls: [String]?
/// annotations contains arbitrary metadata relating to the targeted content.
public var annotations: [String: String]?
/// platform describes the platform which the image in the manifest runs on.
///
/// This should only be used when referring to a manifest.
public var platform: Platform?
/// artifactType specifies the IANA media type of the artifact.
///
/// Used in referrers API responses to indicate the type of each referring artifact.
public let artifactType: String?
public init(
mediaType: String, digest: String, size: Int64, urls: [String]? = nil, annotations: [String: String]? = nil,
platform: Platform? = nil, artifactType: String? = nil
) {
self.mediaType = mediaType
self.digest = digest
self.size = size
self.urls = urls
self.annotations = annotations
self.platform = platform
self.artifactType = artifactType
}
}
@@ -0,0 +1,31 @@
//===----------------------------------------------------------------------===//
// 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
extension FileManager {
func fileSize(atPath path: String) -> Int64? {
do {
let attributes = try attributesOfItem(atPath: path)
guard let fileSize = attributes[.size] as? NSNumber else {
return nil
}
return fileSize.int64Value
} catch {
return nil
}
}
}
@@ -0,0 +1,173 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/config.go
import Foundation
/// ImageConfig defines the execution parameters which should be used as a base when running a container using an image.
public struct ImageConfig: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case user = "User"
case env = "Env"
case entrypoint = "Entrypoint"
case cmd = "Cmd"
case workingDir = "WorkingDir"
case labels = "Labels"
case stopSignal = "StopSignal"
}
/// user defines the username or UID which the process in the container should run as.
public let user: String?
/// env is a list of environment variables to be used in a container.
public let env: [String]?
/// entrypoint defines a list of arguments to use as the command to execute when the container starts.
public let entrypoint: [String]?
/// cmd defines the default arguments to the entrypoint of the container.
public let cmd: [String]?
/// workingDir sets the current working directory of the entrypoint process in the container.
public let workingDir: String?
/// labels contains arbitrary metadata for the container.
public let labels: [String: String]?
/// stopSignal contains the system call signal that will be sent to the container to exit.
public let stopSignal: String?
public init(
user: String? = nil, env: [String]? = nil, entrypoint: [String]? = nil, cmd: [String]? = nil,
workingDir: String? = nil, labels: [String: String]? = nil, stopSignal: String? = nil
) {
self.user = user
self.env = env
self.entrypoint = entrypoint
self.cmd = cmd
self.workingDir = workingDir
self.labels = labels
self.stopSignal = stopSignal
}
}
/// RootFS describes a layer content addresses
public struct Rootfs: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case type
case diffIDs = "diff_ids"
}
/// type is the type of the rootfs.
public let type: String
/// diffIDs is an array of layer content hashes (DiffIDs), in order from bottom-most to top-most.
public let diffIDs: [String]
public init(type: String, diffIDs: [String]) {
self.type = type
self.diffIDs = diffIDs
}
}
/// History describes the history of a layer.
public struct History: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case created
case createdBy = "created_by"
case author
case comment
case emptyLayer = "empty_layer"
}
/// created is the combined date and time at which the layer was created, formatted as defined by RFC 3339, section 5.6.
public let created: String?
/// createdBy is the command which created the layer.
public let createdBy: String?
/// author is the author of the build point.
public let author: String?
/// comment is a custom message set when creating the layer.
public let comment: String?
/// emptyLayer is used to mark if the history item created a filesystem diff.
public let emptyLayer: Bool?
public init(
created: String? = nil, createdBy: String? = nil, author: String? = nil, comment: String? = nil,
emptyLayer: Bool? = nil
) {
self.created = created
self.createdBy = createdBy
self.author = author
self.comment = comment
self.emptyLayer = emptyLayer
}
}
/// Image is the JSON structure which describes some basic information about the image.
/// This provides the `application/vnd.oci.image.config.v1+json` mediatype when marshalled to JSON.
public struct Image: Codable, Sendable {
/// created is the combined date and time at which the image was created, formatted as defined by RFC 3339, section 5.6.
public let created: String?
/// author defines the name and/or email address of the person or entity which created and is responsible for maintaining the image.
public let author: String?
/// architecture field specifies the CPU architecture, for example `amd64` or `ppc64`.
public let architecture: String
/// os specifies the operating system, for example `linux` or `windows`.
public let os: String
/// osVersion is an optional field specifying the operating system version, for example on Windows `10.0.14393.1066`.
public let osVersion: String?
/// osFeatures is an optional field specifying an array of strings, each listing a required OS feature (for example on Windows `win32k`).
public let osFeatures: [String]?
/// variant is an optional field specifying a variant of the CPU, for example `v7` to specify ARMv7 when architecture is `arm`.
public let variant: String?
/// config defines the execution parameters which should be used as a base when running a container using the image.
public let config: ImageConfig?
/// rootfs references the layer content addresses used by the image.
public let rootfs: Rootfs
/// history describes the history of each layer.
public let history: [History]?
public init(
created: String? = nil, author: String? = nil, architecture: String, os: String, osVersion: String? = nil,
osFeatures: [String]? = nil, variant: String? = nil, config: ImageConfig? = nil, rootfs: Rootfs,
history: [History]? = nil
) {
self.created = created
self.author = author
self.architecture = architecture
self.os = os
self.osVersion = osVersion
self.osFeatures = osFeatures
self.variant = variant
self.config = config
self.rootfs = rootfs
self.history = history
}
}
+64
View File
@@ -0,0 +1,64 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/index.go
import Foundation
/// Index references manifests for various platforms.
/// This structure provides `application/vnd.oci.image.index.v1+json` mediatype when marshalled to JSON.
public struct Index: Codable, Sendable {
/// schemaVersion is the image manifest schema that this image follows
public let schemaVersion: Int
/// mediaType specifies the type of this document data structure e.g. `application/vnd.oci.image.index.v1+json`
/// This field is optional per the OCI Image Index Specification (omitempty)
public let mediaType: String
/// manifests references platform specific manifests.
public var manifests: [Descriptor]
/// annotations contains arbitrary metadata for the image index.
public var annotations: [String: String]?
/// `subject` references another manifest this index is an artifact of.
public let subject: Descriptor?
/// `artifactType` specifies the IANA media type of the artifact this index represents.
public let artifactType: String?
public init(
schemaVersion: Int = 2, mediaType: String = MediaTypes.index, manifests: [Descriptor],
annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil
) {
self.schemaVersion = schemaVersion
self.mediaType = mediaType
self.manifests = manifests
self.annotations = annotations
self.subject = subject
self.artifactType = artifactType
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
self.mediaType = try container.decodeIfPresent(String.self, forKey: .mediaType) ?? ""
self.manifests = try container.decode([Descriptor].self, forKey: .manifests)
self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations)
self.subject = try container.decodeIfPresent(Descriptor.self, forKey: .subject)
self.artifactType = try container.decodeIfPresent(String.self, forKey: .artifactType)
}
}
@@ -0,0 +1,57 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/manifest.go
import Foundation
/// Manifest provides `application/vnd.oci.image.manifest.v1+json` mediatype structure when marshalled to JSON.
public struct Manifest: Codable, Sendable {
/// `schemaVersion` is the image manifest schema that this image follows.
public let schemaVersion: Int
/// `mediaType` specifies the type of this document data structure, e.g. `application/vnd.oci.image.manifest.v1+json`.
public let mediaType: String?
/// `config` references a configuration object for a container, by digest.
/// The referenced configuration object is a JSON blob that the runtime uses to set up the container.
public let config: Descriptor
/// `layers` is an indexed list of layers referenced by the manifest.
public let layers: [Descriptor]
/// `annotations` contains arbitrary metadata for the image manifest.
public let annotations: [String: String]?
/// `subject` references another manifest this manifest is an artifact of.
public let subject: Descriptor?
/// `artifactType` specifies the IANA media type of the artifact this manifest represents.
public let artifactType: String?
public init(
schemaVersion: Int = 2, mediaType: String = MediaTypes.imageManifest, config: Descriptor, layers: [Descriptor],
annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil
) {
self.schemaVersion = schemaVersion
self.mediaType = mediaType
self.config = config
self.layers = layers
self.annotations = annotations
self.subject = subject
self.artifactType = artifactType
}
}
@@ -0,0 +1,67 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
/// MediaTypes represent all supported OCI image content types for both metadata and layer formats.
/// Follows all distributable media types in: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/mediatype.go
public struct MediaTypes: Codable, Sendable {
/// Specifies the media type for a content descriptor.
public static let descriptor = "application/vnd.oci.descriptor.v1+json"
/// Specifies the media type for the oci-layout.
public static let layoutHeader = "application/vnd.oci.layout.header.v1+json"
/// Specifies the media type for an image index.
public static let index = "application/vnd.oci.image.index.v1+json"
/// Specifies the media type for an image manifest.
public static let imageManifest = "application/vnd.oci.image.manifest.v1+json"
/// Specifies the media type for the image configuration.
public static let imageConfig = "application/vnd.oci.image.config.v1+json"
/// Specifies the media type for an unused blob containing the value "{}".
public static let emptyJSON = "application/vnd.oci.empty.v1+json"
/// Specifies the media type for a Docker image manifest.
public static let dockerManifest = "application/vnd.docker.distribution.manifest.v2+json"
/// Specifies the media type for a Docker image manifest list.
public static let dockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
/// The Docker media type used for image configurations.
public static let dockerImageConfig = "application/vnd.docker.container.image.v1+json"
/// The media type used for layers referenced by the manifest.
public static let imageLayer = "application/vnd.oci.image.layer.v1.tar"
/// The media type used for gzipped layers referenced by the manifest.
public static let imageLayerGzip = "application/vnd.oci.image.layer.v1.tar+gzip"
/// The media type used for zstd compressed layers referenced by the manifest.
public static let imageLayerZstd = "application/vnd.oci.image.layer.v1.tar+zstd"
/// The Docker media type used for uncompressed layers referenced by an image manifest.
public static let dockerImageLayer = "application/vnd.docker.image.rootfs.diff.tar"
/// The Docker media type used for gzipped layers referenced by an image manifest.
public static let dockerImageLayerGzip = "application/vnd.docker.image.rootfs.diff.tar.gzip"
/// The Docker media type used for zstd compressed layers referenced by an image manifest.
public static let dockerImageLayerZstd = "application/vnd.docker.image.rootfs.diff.tar.zstd"
/// The media type used for in-toto attestations blobs.
public static let inTotoAttestationBlob = "application/vnd.in-toto+json"
}
+340
View File
@@ -0,0 +1,340 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/config.go
import ContainerizationError
import Foundation
/// Platform describes the platform which the image in the manifest runs on.
public struct Platform: Sendable, Equatable {
/// Normalizes a raw architecture string (e.g. from uname) to its OCI equivalent.
static func normalizeArch(_ raw: String) -> (arch: String, variant: String?) {
switch raw {
case "aarch64", "arm64":
return ("arm64", "v8")
case "x86_64", "x86-64", "amd64":
return ("amd64", nil)
case "arm", "armhf", "armel":
return ("arm", "v7")
default:
return (raw, nil)
}
}
public static var current: Self {
var systemInfo = utsname()
uname(&systemInfo)
let arch = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(cString: $0)
}
}
let normalized = normalizeArch(arch)
return .init(arch: normalized.arch, os: "linux", variant: normalized.variant)
}
/// The computed description, for example, `linux/arm64/v8`.
public var description: String {
let architecture = architecture
if let variant = variant {
return "\(os)/\(architecture)/\(variant)"
}
return "\(os)/\(architecture)"
}
/// The CPU architecture, for example, `amd64` or `arm64`.
public var architecture: String {
Self.normalizeArch(_rawArch).arch
}
/// The operating system, for example, `linux` or `windows`.
public var os: String {
_rawOS
}
/// An optional field specifying the operating system version, for example on Windows `10.0.14393.1066`.
public var osVersion: String?
/// An optional field specifying an array of strings, each listing a required OS feature (for example on Windows `win32k`).
public var osFeatures: [String]?
/// An optional field specifying a variant of the CPU, for example `v7` to specify ARMv7 when architecture is `arm`.
public var variant: String?
/// The operation system of the image (eg. `linux`).
private let _rawOS: String
/// The CPU architecture (eg. `arm64`).
private let _rawArch: String
public init(arch: String, os: String, osVersion: String? = nil, osFeatures: [String]? = nil, variant: String? = nil) {
self._rawArch = arch
self._rawOS = os
self.osVersion = osVersion
self.osFeatures = osFeatures
self.variant = variant
}
/// Initializes a new platform from a string.
/// - Parameters:
/// - platform: A `string` value representing the platform.
/// ```swift
/// // Create a new `ImagePlatform` from string.
/// let platform = try Platform(from: "linux/amd64")
/// ```
/// ## Throws ##
/// - Throws: `Error.missingOS` if input is empty
/// - Throws: `Error.invalidOS` if os is not `linux`
/// - Throws: `Error.missingArch` if only one `/` is present
/// - Throws: `Error.invalidArch` if an unrecognized architecture is provided
/// - Throws: `Error.invalidVariant` if a variant is provided, and it does not apply to the specified architecture
public init(from platform: String) throws {
let items = platform.split(separator: "/", maxSplits: 1)
guard let osValue = items.first else {
throw ContainerizationError(.invalidArgument, message: "missing OS in \(platform)")
}
switch osValue {
case "linux", "windows", "darwin":
_rawOS = osValue.description
default:
throw ContainerizationError(.invalidArgument, message: "unknown OS in \(osValue)")
}
guard items.count > 1 else {
throw ContainerizationError(.invalidArgument, message: "missing architecture in \(platform)")
}
guard let archItems = items.last?.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) else {
throw ContainerizationError(.invalidArgument, message: "missing architecture in \(platform)")
}
guard let archName = archItems.first else {
throw ContainerizationError(.invalidArgument, message: "missing architecture in \(platform)")
}
switch archName {
case "arm", "armhf", "armel":
_rawArch = "arm"
variant = "v7"
case "aarch64", "arm64":
variant = "v8"
_rawArch = "arm64"
case "x86_64", "x86-64", "amd64":
_rawArch = "amd64"
default:
_rawArch = archName.description
}
if archItems.count == 2 {
guard let archVariant = archItems.last else {
throw ContainerizationError(.invalidArgument, message: "missing variant in \(platform)")
}
switch archName {
case "arm":
switch archVariant {
case "v5", "v6", "v7", "v8":
variant = archVariant.description
default:
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
}
case "armhf":
switch archVariant {
case "v7":
variant = "v7"
default:
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
}
case "armel":
switch archVariant {
case "v6":
variant = "v6"
default:
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
}
case "aarch64", "arm64":
switch archVariant {
case "v8", "8":
variant = "v8"
default:
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
}
case "x86_64", "x86-64", "amd64":
switch archVariant {
case "v1":
variant = nil
default:
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
}
case "i386", "386", "ppc64le", "riscv64":
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
default:
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
}
}
}
}
extension Platform: Hashable {
/// `~=` compares two platforms to check if **lhs** platform images are compatible with **rhs** platform
/// This operator can be used to check if an image of **lhs** platform can run on **rhs**:
/// - `true`: when **rhs**=`arm/v8`, **lhs** is any of `arm/v8`, `arm/v7`, `arm/v6` and `arm/v5`
/// - `true`: when **rhs**=`arm/v7`, **lhs** is any of `arm/v7`, `arm/v6` and `arm/v5`
/// - `true`: when **rhs**=`arm/v6`, **lhs** is any of `arm/v6` and `arm/v5`
/// - `true`: when **rhs**=`amd64`, **lhs** is any of `amd64` and `386`
/// - `true`: when **rhs**=**lhs**
/// - `false`: otherwise
/// - Parameters:
/// - lhs: platform whose compatibility is being checked
/// - rhs: platform against which compatibility is being checked
/// - Returns: `true | false`
public static func ~= (lhs: Platform, rhs: Platform) -> Bool {
if lhs.os == rhs.os {
if lhs._rawArch == rhs._rawArch {
switch rhs._rawArch {
case "arm":
guard let lVariant = lhs.variant else {
return lhs == rhs
}
guard let rVariant = rhs.variant else {
return lhs == rhs
}
switch rVariant {
case "v8":
switch lVariant {
case "v5", "v6", "v7", "v8":
return true
default:
return false
}
case "v7":
switch lVariant {
case "v5", "v6", "v7":
return true
default:
return false
}
case "v6":
switch lVariant {
case "v5", "v6":
return true
default:
return false
}
default:
return lhs == rhs
}
default:
return lhs == rhs
}
}
if lhs._rawArch == "386" && rhs._rawArch == "amd64" {
return true
}
}
return false
}
/// `==` compares if **lhs** and **rhs** are the exact same platforms.
public static func == (lhs: Platform, rhs: Platform) -> Bool {
// NOTE:
// If the platform struct was created by setting the fields directly and not using (from: String)
// then, there is a possibility that for arm64 architecture, the variant may be set to nil
// In that case, the variant should be assumed to v8
if lhs.architecture == "arm64" && rhs.architecture == "arm64" {
// The following checks effectively verify
// that one operand has nil value and other has "v8"
if lhs.variant == nil || rhs.variant == nil {
if lhs.variant == "v8" || rhs.variant == "v8" {
return true
}
}
}
let osEqual = lhs.os == rhs.os
let archEqual = lhs.architecture == rhs.architecture
let variantEqual = lhs.variant == rhs.variant
return osEqual && archEqual && variantEqual
}
public func hash(into hasher: inout Swift.Hasher) {
hasher.combine(os)
hasher.combine(architecture)
// arm64 with no variant is equivalent to arm64/v8 per the == implementation
if architecture == "arm64" {
hasher.combine(variant ?? "v8")
} else {
hasher.combine(variant)
}
}
}
extension Platform: Codable {
enum CodingKeys: String, CodingKey {
case os = "os"
case architecture = "architecture"
case variant = "variant"
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(os, forKey: .os)
try container.encode(architecture, forKey: .architecture)
try container.encodeIfPresent(variant, forKey: .variant)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let architecture = try container.decodeIfPresent(String.self, forKey: .architecture)
guard let architecture else {
throw ContainerizationError(.invalidArgument, message: "missing architecture")
}
let os = try container.decodeIfPresent(String.self, forKey: .os)
guard let os else {
throw ContainerizationError(.invalidArgument, message: "missing OS")
}
let variant = try container.decodeIfPresent(String.self, forKey: .variant)
self.init(arch: architecture, os: os, variant: variant)
}
}
public func createPlatformMatcher(for platform: Platform?) -> @Sendable (Platform) -> Bool {
if let platform {
return { other in
platform == other
}
}
return { _ in
true
}
}
public func filterPlatforms(matcher: (Platform) -> Bool, _ descriptors: [Descriptor]) throws -> [Descriptor] {
var outDescriptors: [Descriptor] = []
for desc in descriptors {
guard let p = desc.platform else {
// pass along descriptor if the platform is not defined
outDescriptors.append(desc)
continue
}
if matcher(p) {
outDescriptors.append(desc)
}
}
return outDescriptors
}
+285
View File
@@ -0,0 +1,285 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
// nameTotalLengthMax matches the OCI distribution spec which allows up to 255 bytes for the
// repository name component (domain + "/" + path).
private let nameTotalLengthMax = 255
// referenceTotalLengthMax is the upper bound for the full reference string: max name (255) +
// separator (1) + max tag length (128) = 384.
private let tagLengthMax = 128
private let referenceTotalLengthMax = nameTotalLengthMax + 1 + tagLengthMax
private let legacyDockerRegistryHost = "docker.io"
private let dockerRegistryHost = "registry-1.docker.io"
private let defaultDockerRegistryRepo = "library"
private let defaultTag = "latest"
/// A Reference is composed of the various parts of an OCI image reference.
/// For example:
/// let imageReference = "my-registry.com/repository/image:tag2"
/// let reference = Reference.parse(imageReference)
/// print(reference.domain!) // gives us "my-registry.com"
/// print(reference.name) // gives us "my-registry.com/repository/image"
/// print(reference.path) // gives us "repository/image"
/// print(reference.tag!) // gives us "tag2"
/// print(reference.digest) // gives us "nil"
public class Reference: CustomStringConvertible {
private var _domain: String?
public var domain: String? {
_domain
}
public var resolvedDomain: String? {
if let d = _domain {
return Self.resolveDomain(domain: d)
}
return nil
}
private var _path: String
public var path: String {
_path
}
private var _tag: String?
public var tag: String? {
_tag
}
private var _digest: String?
public var digest: String? {
_digest
}
public var name: String {
if let domain, !domain.isEmpty {
return "\(domain)/\(path)"
}
return path
}
public var description: String {
if let tag {
return "\(name):\(tag)"
}
if let digest {
return "\(name)@\(digest)"
}
return name
}
static let identifierPattern = "([a-f0-9]{64})"
static let domainPattern = {
let domainNameComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
let optionalPort = "(?::[0-9]+)?"
let ipv6address = "\\[(?:[a-fA-F0-9:]+)\\]"
let domainName = "\(domainNameComponent)(?:\\.\(domainNameComponent))*"
let host = "(?:\(domainName)|\(ipv6address))"
let domainAndPort = "\(host)\(optionalPort)"
return domainAndPort
}()
static let pathPattern = "(?<path>(?:[a-z0-9]+(?:[._]|__|-|/)?)*[a-z0-9]+)"
static let tagPattern = "(?::(?<tag>[\\w][\\w.-]{0,127}))?(?:@(?<digest>sha256:[0-9a-fA-F]{64}))?"
static let pathTagPattern = "\(pathPattern)\(tagPattern)"
public init(path: String, domain: String? = nil, tag: String? = nil, digest: String? = nil) throws {
if let domain, !domain.isEmpty {
self._domain = domain
}
self._path = path
self._tag = tag
self._digest = digest
}
public static func parse(_ s: String) throws -> Reference {
if s.count > referenceTotalLengthMax {
throw ContainerizationError(.invalidArgument, message: "reference length \(s.count) greater than \(referenceTotalLengthMax)")
}
let identifierRegex = try Regex(Self.identifierPattern)
guard try identifierRegex.wholeMatch(in: s) == nil else {
throw ContainerizationError(.invalidArgument, message: "cannot specify 64 byte hex string as reference")
}
let (domain, remainder) = try Self.parseDomain(from: s)
let constructedRawReference: String = remainder
if let domain {
let domainRegex = try Regex(domainPattern)
guard try domainRegex.wholeMatch(in: domain) != nil else {
throw ContainerizationError(.invalidArgument, message: "invalid domain \(domain) for reference \(s)")
}
}
let fields = try constructedRawReference.matches(regex: pathTagPattern)
guard let path = fields["path"] else {
throw ContainerizationError(.invalidArgument, message: "cannot parse path for reference \(s)")
}
let ref = try Reference(path: path, domain: domain)
if ref.name.count > nameTotalLengthMax {
throw ContainerizationError(.invalidArgument, message: "repo length \(ref.name.count) greater than \(nameTotalLengthMax)")
}
// Extract tag and digest
let tag = fields["tag"] ?? ""
let digest = fields["digest"] ?? ""
if !digest.isEmpty {
return try ref.withDigest(digest)
} else if !tag.isEmpty {
return try ref.withTag(tag)
}
return ref
}
private static func parseDomain(from s: String) throws -> (domain: String?, remainder: String) {
var domain: String? = nil
var path: String = s
let charset = CharacterSet(charactersIn: ".:")
let splits = s.split(separator: "/", maxSplits: 1)
guard splits.count == 2 else {
if s.starts(with: "localhost") {
return (s, "")
}
return (nil, s)
}
let _domain = String(splits[0])
let _path = String(splits[1])
if _domain.starts(with: "localhost") || _domain.rangeOfCharacter(from: charset) != nil {
domain = _domain
path = _path
}
return (domain, path)
}
public static func withName(_ name: String) throws -> Reference {
if name.count > nameTotalLengthMax {
throw ContainerizationError(.invalidArgument, message: "name length \(name.count) greater than \(nameTotalLengthMax)")
}
let fields = try name.matches(regex: Self.domainPattern)
// Extract domain and path
let domain = fields["domain"] ?? ""
let path = fields["path"] ?? ""
if domain.isEmpty || path.isEmpty {
throw ContainerizationError(.invalidArgument, message: "image reference domain or path is empty")
}
return try Reference(path: path, domain: domain)
}
public func withTag(_ tag: String) throws -> Reference {
var tag = tag
if !tag.starts(with: ":") {
tag = ":" + tag
}
let fields = try tag.matches(regex: Self.tagPattern)
tag = fields["tag"] ?? ""
if tag.isEmpty {
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference, missing tag")
}
return try Reference(path: self.path, domain: self.domain, tag: tag)
}
public func withDigest(_ digest: String) throws -> Reference {
var digest = digest
if !digest.starts(with: "@") {
digest = "@" + digest
}
let fields = try digest.matches(regex: Self.tagPattern)
digest = fields["digest"] ?? ""
if digest.isEmpty {
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference, missing digest")
}
return try Reference(path: self.path, domain: self.domain, digest: digest)
}
private static func splitDomain(_ name: String) -> (domain: String, path: String) {
let parts = name.split(separator: "/")
guard parts.count == 2 else {
return ("", name)
}
return (String(parts[0]), String(parts[1]))
}
/// Normalize the reference object.
/// Normalization is useful in cases where the reference object is to be used to
/// fetch/push an image from/to a remote registry.
/// It does the following:
/// - Adds a default tag of "latest" if the reference had no tag/digest set.
/// - If the domain is "registry-1.docker.io" or "docker.io" and the path has no repository set,
/// it adds a default "library/" repository name.
public func normalize() {
if let domain = self.domain, domain == dockerRegistryHost || domain == legacyDockerRegistryHost {
// Check if the image is being referenced by a named tag.
// If it is, and a repository is not specified, prefix it with "library/".
// This needs to be done only if we are using the Docker registry.
if !self.path.contains("/") {
self._path = "\(defaultDockerRegistryRepo)/\(self._path)"
}
}
let identifier = self._tag ?? self._digest
if identifier == nil {
// If the user did not specify a tag or a digest for the reference, set the tag to "latest".
self._tag = defaultTag
}
}
public static func resolveDomain(domain: String) -> String {
if domain == legacyDockerRegistryHost {
return dockerRegistryHost
}
return domain
}
}
extension String {
func matches(regex: String) throws -> [String: String] {
do {
let regex = try NSRegularExpression(pattern: regex, options: [])
let nsRange = NSRange(self.startIndex..<self.endIndex, in: self)
guard let match = regex.firstMatch(in: self, options: [], range: nsRange), match.range == nsRange else {
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference")
}
var results = [String: String]()
for name in try regex.captureGroupNames() {
if let range = Range(match.range(withName: name), in: self) {
results[name] = String(self[range])
}
}
return results
} catch {
throw error
}
}
}
extension NSRegularExpression {
func captureGroupNames() throws -> [String] {
let pattern = self.pattern
let regex = try NSRegularExpression(pattern: "\\(\\?<(\\w+)>", options: [])
let nsRange = NSRange(pattern.startIndex..<pattern.endIndex, in: pattern)
let matches = regex.matches(in: pattern, options: [], range: nsRange)
return matches.map {
String(pattern[Range($0.range(at: 1), in: pattern)!])
}
}
}
+939
View File
@@ -0,0 +1,939 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
/// NOTE: This is not a complete recreation of the runtime spec. Other platforms outside of Linux
/// have been left off, and some APIs for Linux aren't present. This was manually ported starting
/// at the v1.2.0 release.
public struct Spec: Codable, Sendable {
public var version: String
public var hooks: Hooks?
public var process: Process?
public var hostname, domainname: String
public var mounts: [Mount]
public var annotations: [String: String]?
public var root: Root?
public var linux: Linux?
public init(
version: String = "",
hooks: Hooks? = nil,
process: Process? = nil,
hostname: String = "",
domainname: String = "",
mounts: [Mount] = [],
annotations: [String: String]? = nil,
root: Root? = nil,
linux: Linux? = nil
) {
self.version = version
self.hooks = hooks
self.process = process
self.hostname = hostname
self.domainname = domainname
self.mounts = mounts
self.annotations = annotations
self.root = root
self.linux = linux
}
public enum CodingKeys: String, CodingKey {
case version = "ociVersion"
case hooks
case process
case hostname
case domainname
case mounts
case annotations
case root
case linux
}
public init(from decoder: Decoder) throws {
self.init()
let container = try decoder.container(keyedBy: CodingKeys.self)
self.version = try container.decode(String.self, forKey: .version)
self.hooks = try container.decodeIfPresent(Hooks.self, forKey: .hooks)
self.process = try container.decodeIfPresent(Process.self, forKey: .process)
if let hostname = try container.decodeIfPresent(String.self, forKey: .hostname) {
self.hostname = hostname
}
if let domainname = try container.decodeIfPresent(String.self, forKey: .domainname) {
self.domainname = domainname
}
if let mounts = try container.decodeIfPresent([Mount].self, forKey: .mounts) {
self.mounts = mounts
}
self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations)
self.root = try container.decodeIfPresent(Root.self, forKey: .root)
self.linux = try container.decodeIfPresent(Linux.self, forKey: .linux)
}
}
public struct Process: Codable, Sendable {
public var cwd: String
public var env: [String]
public var consoleSize: Box?
public var selinuxLabel: String
public var noNewPrivileges: Bool
public var commandLine: String
public var oomScoreAdj: Int?
public var capabilities: LinuxCapabilities?
public var apparmorProfile: String
public var user: User
public var rlimits: [POSIXRlimit]
public var args: [String]
public var terminal: Bool
public enum CodingKeys: String, CodingKey {
case cwd
case env
case consoleSize
case selinuxLabel
case noNewPrivileges
case commandLine
case oomScoreAdj
case capabilities
case apparmorProfile
case user
case rlimits
case args
case terminal
}
public init(
args: [String] = [],
cwd: String = "/",
env: [String] = [],
consoleSize: Box? = nil,
selinuxLabel: String = "",
noNewPrivileges: Bool = false,
commandLine: String = "",
oomScoreAdj: Int? = nil,
capabilities: LinuxCapabilities? = nil,
apparmorProfile: String = "",
user: User = .init(),
rlimits: [POSIXRlimit] = [],
terminal: Bool = false
) {
self.cwd = cwd
self.env = env
self.consoleSize = consoleSize
self.selinuxLabel = selinuxLabel
self.noNewPrivileges = noNewPrivileges
self.commandLine = commandLine
self.oomScoreAdj = oomScoreAdj
self.capabilities = capabilities
self.apparmorProfile = apparmorProfile
self.user = user
self.rlimits = rlimits
self.args = args
self.terminal = terminal
}
public init(from config: ImageConfig) {
let cwd = config.workingDir ?? "/"
let env = config.env ?? []
let args = (config.entrypoint ?? []) + (config.cmd ?? [])
let user: User = {
if let rawString = config.user {
return User(username: rawString)
}
return User()
}()
self.init(args: args, cwd: cwd, env: env, user: user)
}
public init(from decoder: Decoder) throws {
self.init()
let container = try decoder.container(keyedBy: CodingKeys.self)
self.cwd = try container.decode(String.self, forKey: .cwd)
if let env = try container.decodeIfPresent([String].self, forKey: .env) {
self.env = env
}
self.consoleSize = try container.decodeIfPresent(Box.self, forKey: .consoleSize)
if let selinuxLabel = try container.decodeIfPresent(String.self, forKey: .selinuxLabel) {
self.selinuxLabel = selinuxLabel
}
if let noNewPrivileges = try container.decodeIfPresent(Bool.self, forKey: .noNewPrivileges) {
self.noNewPrivileges = noNewPrivileges
}
if let commandLine = try container.decodeIfPresent(String.self, forKey: .commandLine) {
self.commandLine = commandLine
}
self.oomScoreAdj = try container.decodeIfPresent(Int.self, forKey: .oomScoreAdj)
self.capabilities = try container.decodeIfPresent(LinuxCapabilities.self, forKey: .capabilities)
if let apparmorProfile = try container.decodeIfPresent(String.self, forKey: .apparmorProfile) {
self.apparmorProfile = apparmorProfile
}
self.user = try container.decode(User.self, forKey: .user)
if let rlimits = try container.decodeIfPresent([POSIXRlimit].self, forKey: .rlimits) {
self.rlimits = rlimits
}
if let args = try container.decodeIfPresent([String].self, forKey: .args) {
self.args = args
}
if let terminal = try container.decodeIfPresent(Bool.self, forKey: .terminal) {
self.terminal = terminal
}
}
}
public struct LinuxCapabilities: Codable, Sendable {
public var bounding: [String]?
public var effective: [String]?
public var inheritable: [String]?
public var permitted: [String]?
public var ambient: [String]?
enum CodingKeys: String, CodingKey {
case bounding
case effective
case inheritable
case permitted
case ambient
}
public init(
bounding: [String]? = nil,
effective: [String]? = nil,
inheritable: [String]? = nil,
permitted: [String]? = nil,
ambient: [String]? = nil
) {
self.bounding = bounding
self.effective = effective
self.inheritable = inheritable
self.permitted = permitted
self.ambient = ambient
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.bounding = try container.decodeIfPresent([String].self, forKey: .bounding)
self.effective = try container.decodeIfPresent([String].self, forKey: .effective)
self.inheritable = try container.decodeIfPresent([String].self, forKey: .inheritable)
self.permitted = try container.decodeIfPresent([String].self, forKey: .permitted)
self.ambient = try container.decodeIfPresent([String].self, forKey: .ambient)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(bounding, forKey: .bounding)
try container.encodeIfPresent(effective, forKey: .effective)
try container.encodeIfPresent(inheritable, forKey: .inheritable)
try container.encodeIfPresent(permitted, forKey: .permitted)
try container.encodeIfPresent(ambient, forKey: .ambient)
}
}
public struct Box: Codable, Sendable {
var height, width: UInt
public init(height: UInt, width: UInt) {
self.height = height
self.width = width
}
}
public struct User: Codable, Sendable {
public var uid: UInt32
public var gid: UInt32
public var umask: UInt32?
public var additionalGids: [UInt32]
public var username: String
public enum CodingKeys: String, CodingKey {
case uid
case gid
case umask
case additionalGids
case username
}
public init(
uid: UInt32 = 0,
gid: UInt32 = 0,
umask: UInt32? = nil,
additionalGids: [UInt32] = [],
username: String = ""
) {
self.uid = uid
self.gid = gid
self.umask = umask
self.additionalGids = additionalGids
self.username = username
}
public init(from decoder: Decoder) throws {
self.init()
let container = try decoder.container(keyedBy: CodingKeys.self)
self.uid = try container.decode(UInt32.self, forKey: .uid)
self.gid = try container.decode(UInt32.self, forKey: .gid)
self.umask = try container.decodeIfPresent(UInt32.self, forKey: .umask)
if let additionalGids = try container.decodeIfPresent([UInt32].self, forKey: .additionalGids) {
self.additionalGids = additionalGids
}
if let username = try container.decodeIfPresent(String.self, forKey: .username) {
self.username = username
}
}
}
public struct Root: Codable, Sendable {
public var path: String
public var readonly: Bool
public enum CodingKeys: String, CodingKey {
case path
case readonly
}
public init(path: String, readonly: Bool) {
self.path = path
self.readonly = readonly
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.path = try container.decode(String.self, forKey: .path)
self.readonly = try container.decodeIfPresent(Bool.self, forKey: .readonly) ?? false
}
}
public struct Mount: Codable, Sendable {
public var type: String
public var source: String
public var destination: String
public var options: [String]
public var uidMappings: [LinuxIDMapping]?
public var gidMappings: [LinuxIDMapping]?
public enum CodingKeys: String, CodingKey {
case type
case source
case destination
case options
case uidMappings
case gidMappings
}
public init(
type: String = "",
source: String = "",
destination: String,
options: [String] = [],
uidMappings: [LinuxIDMapping]? = nil,
gidMappings: [LinuxIDMapping]? = nil
) {
self.destination = destination
self.type = type
self.source = source
self.options = options
self.uidMappings = uidMappings
self.gidMappings = gidMappings
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
self.source = try container.decodeIfPresent(String.self, forKey: .source) ?? ""
self.destination = try container.decode(String.self, forKey: .destination)
self.options = try container.decodeIfPresent([String].self, forKey: .options) ?? []
self.uidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .uidMappings)
self.gidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .gidMappings)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
try container.encode(source, forKey: .source)
try container.encode(destination, forKey: .destination)
try container.encode(options, forKey: .options)
try container.encodeIfPresent(uidMappings, forKey: .uidMappings)
try container.encodeIfPresent(gidMappings, forKey: .gidMappings)
}
}
public struct Hook: Codable, Sendable {
public var path: String
public var args: [String]
public var env: [String]
public var timeout: Int?
public init(path: String, args: [String], env: [String], timeout: Int?) {
self.path = path
self.args = args
self.env = env
self.timeout = timeout
}
}
public struct Hooks: Codable, Sendable {
public var prestart: [Hook]
public var createRuntime: [Hook]
public var createContainer: [Hook]
public var startContainer: [Hook]
public var poststart: [Hook]
public var poststop: [Hook]
public init(
prestart: [Hook],
createRuntime: [Hook],
createContainer: [Hook],
startContainer: [Hook],
poststart: [Hook],
poststop: [Hook]
) {
self.prestart = prestart
self.createRuntime = createRuntime
self.createContainer = createContainer
self.startContainer = startContainer
self.poststart = poststart
self.poststop = poststop
}
}
public struct Linux: Codable, Sendable {
public var uidMappings: [LinuxIDMapping]
public var gidMappings: [LinuxIDMapping]
public var sysctl: [String: String]?
public var resources: LinuxResources?
public var cgroupsPath: String
public var namespaces: [LinuxNamespace]
public var devices: [LinuxDevice]
public var seccomp: LinuxSeccomp?
public var rootfsPropagation: String
public var maskedPaths: [String]
public var readonlyPaths: [String]
public var mountLabel: String
public var personality: LinuxPersonality?
public enum CodingKeys: String, CodingKey {
case uidMappings
case gidMappings
case sysctl
case resources
case cgroupsPath
case namespaces
case devices
case seccomp
case rootfsPropagation
case maskedPaths
case readonlyPaths
case mountLabel
case personality
}
public init(
uidMappings: [LinuxIDMapping] = [],
gidMappings: [LinuxIDMapping] = [],
sysctl: [String: String]? = nil,
resources: LinuxResources? = nil,
cgroupsPath: String = "",
namespaces: [LinuxNamespace] = [],
devices: [LinuxDevice] = [],
seccomp: LinuxSeccomp? = nil,
rootfsPropagation: String = "",
maskedPaths: [String] = [],
readonlyPaths: [String] = [],
mountLabel: String = "",
personality: LinuxPersonality? = nil
) {
self.uidMappings = uidMappings
self.gidMappings = gidMappings
self.sysctl = sysctl
self.resources = resources
self.cgroupsPath = cgroupsPath
self.namespaces = namespaces
self.devices = devices
self.seccomp = seccomp
self.rootfsPropagation = rootfsPropagation
self.maskedPaths = maskedPaths
self.readonlyPaths = readonlyPaths
self.mountLabel = mountLabel
self.personality = personality
}
public init(from decoder: Decoder) throws {
self.init()
let container = try decoder.container(keyedBy: CodingKeys.self)
if let uidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .uidMappings) {
self.uidMappings = uidMappings
}
if let gidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .gidMappings) {
self.gidMappings = gidMappings
}
self.sysctl = try container.decodeIfPresent([String: String].self, forKey: .sysctl)
self.resources = try container.decodeIfPresent(LinuxResources.self, forKey: .resources)
if let cgroupsPath = try container.decodeIfPresent(String.self, forKey: .cgroupsPath) {
self.cgroupsPath = cgroupsPath
}
if let namespaces = try container.decodeIfPresent([LinuxNamespace].self, forKey: .namespaces) {
self.namespaces = namespaces
}
if let devices = try container.decodeIfPresent([LinuxDevice].self, forKey: .devices) {
self.devices = devices
}
self.seccomp = try container.decodeIfPresent(LinuxSeccomp.self, forKey: .seccomp)
if let rootfsPropagation = try container.decodeIfPresent(String.self, forKey: .rootfsPropagation) {
self.rootfsPropagation = rootfsPropagation
}
if let maskedPaths = try container.decodeIfPresent([String].self, forKey: .maskedPaths) {
self.maskedPaths = maskedPaths
}
if let readonlyPaths = try container.decodeIfPresent([String].self, forKey: .readonlyPaths) {
self.readonlyPaths = readonlyPaths
}
if let mountLabel = try container.decodeIfPresent(String.self, forKey: .mountLabel) {
self.mountLabel = mountLabel
}
self.personality = try container.decodeIfPresent(LinuxPersonality.self, forKey: .personality)
}
}
public struct LinuxNamespace: Codable, Sendable {
public var type: LinuxNamespaceType
public var path: String
public init(type: LinuxNamespaceType, path: String = "") {
self.type = type
self.path = path
}
}
public enum LinuxNamespaceType: String, Codable, Sendable {
case pid
case network
case uts
case mount
case ipc
case user
case cgroup
}
public struct LinuxIDMapping: Codable, Sendable {
public var containerID: UInt32
public var hostID: UInt32
public var size: UInt32
public init(containerID: UInt32, hostID: UInt32, size: UInt32) {
self.containerID = containerID
self.hostID = hostID
self.size = size
}
}
public struct POSIXRlimit: Codable, Sendable {
public var type: String
public var hard: UInt64
public var soft: UInt64
public init(type: String, hard: UInt64, soft: UInt64) {
self.type = type
self.hard = hard
self.soft = soft
}
}
public struct LinuxHugepageLimit: Codable, Sendable {
public var pagesize: String
public var limit: UInt64
public init(pagesize: String, limit: UInt64) {
self.pagesize = pagesize
self.limit = limit
}
}
public struct LinuxInterfacePriority: Codable, Sendable {
public var name: String
public var priority: UInt32
public init(name: String, priority: UInt32) {
self.name = name
self.priority = priority
}
}
public struct LinuxBlockIODevice: Codable, Sendable {
public var major: Int64
public var minor: Int64
public init(major: Int64, minor: Int64) {
self.major = major
self.minor = minor
}
}
public struct LinuxWeightDevice: Codable, Sendable {
public var major: Int64
public var minor: Int64
public var weight: UInt16?
public var leafWeight: UInt16?
public init(major: Int64, minor: Int64, weight: UInt16?, leafWeight: UInt16?) {
self.major = major
self.minor = minor
self.weight = weight
self.leafWeight = leafWeight
}
}
public struct LinuxThrottleDevice: Codable, Sendable {
public var major: Int64
public var minor: Int64
public var rate: UInt64
public init(major: Int64, minor: Int64, rate: UInt64) {
self.major = major
self.minor = minor
self.rate = rate
}
}
public struct LinuxBlockIO: Codable, Sendable {
public var weight: UInt16?
public var leafWeight: UInt16?
public var weightDevice: [LinuxWeightDevice]
public var throttleReadBpsDevice: [LinuxThrottleDevice]
public var throttleWriteBpsDevice: [LinuxThrottleDevice]
public var throttleReadIOPSDevice: [LinuxThrottleDevice]
public var throttleWriteIOPSDevice: [LinuxThrottleDevice]
public init(
weight: UInt16?,
leafWeight: UInt16?,
weightDevice: [LinuxWeightDevice],
throttleReadBpsDevice: [LinuxThrottleDevice],
throttleWriteBpsDevice: [LinuxThrottleDevice],
throttleReadIOPSDevice: [LinuxThrottleDevice],
throttleWriteIOPSDevice: [LinuxThrottleDevice]
) {
self.weight = weight
self.leafWeight = leafWeight
self.weightDevice = weightDevice
self.throttleReadBpsDevice = throttleReadBpsDevice
self.throttleWriteBpsDevice = throttleWriteBpsDevice
self.throttleReadIOPSDevice = throttleReadIOPSDevice
self.throttleWriteIOPSDevice = throttleWriteIOPSDevice
}
}
public struct LinuxMemory: Codable, Sendable {
public var limit: Int64?
public var reservation: Int64?
public var swap: Int64?
public var kernel: Int64?
public var kernelTCP: Int64?
public var swappiness: UInt64?
public var disableOOMKiller: Bool?
public var useHierarchy: Bool?
public var checkBeforeUpdate: Bool?
public init(
limit: Int64? = nil,
reservation: Int64? = nil,
swap: Int64? = nil,
kernel: Int64? = nil,
kernelTCP: Int64? = nil,
swappiness: UInt64? = nil,
disableOOMKiller: Bool? = nil,
useHierarchy: Bool? = nil,
checkBeforeUpdate: Bool? = nil
) {
self.limit = limit
self.reservation = reservation
self.swap = swap
self.kernel = kernel
self.kernelTCP = kernelTCP
self.swappiness = swappiness
self.disableOOMKiller = disableOOMKiller
self.useHierarchy = useHierarchy
self.checkBeforeUpdate = checkBeforeUpdate
}
}
public struct LinuxCPU: Codable, Sendable {
public var shares: UInt64?
public var quota: Int64?
public var burst: UInt64?
public var period: UInt64?
public var realtimeRuntime: Int64?
public var realtimePeriod: Int64?
public var cpus: String
public var mems: String
public var idle: Int64?
public init(
shares: UInt64? = nil,
quota: Int64? = nil,
burst: UInt64? = nil,
period: UInt64? = nil,
realtimeRuntime: Int64? = nil,
realtimePeriod: Int64? = nil,
cpus: String = "",
mems: String = "",
idle: Int64? = nil
) {
self.shares = shares
self.quota = quota
self.burst = burst
self.period = period
self.realtimeRuntime = realtimeRuntime
self.realtimePeriod = realtimePeriod
self.cpus = cpus
self.mems = mems
self.idle = idle
}
}
public struct LinuxPids: Codable, Sendable {
public var limit: Int64
public init(limit: Int64) {
self.limit = limit
}
}
public struct LinuxNetwork: Codable, Sendable {
public var classID: UInt32?
public var priorities: [LinuxInterfacePriority]
public init(classID: UInt32?, priorities: [LinuxInterfacePriority]) {
self.classID = classID
self.priorities = priorities
}
}
public struct LinuxRdma: Codable, Sendable {
public var hcsHandles: UInt32?
public var hcaObjects: UInt32?
public init(hcsHandles: UInt32?, hcaObjects: UInt32?) {
self.hcsHandles = hcsHandles
self.hcaObjects = hcaObjects
}
}
public struct LinuxResources: Codable, Sendable {
public var devices: [LinuxDeviceCgroup]
public var memory: LinuxMemory?
public var cpu: LinuxCPU?
public var pids: LinuxPids?
public var blockIO: LinuxBlockIO?
public var hugepageLimits: [LinuxHugepageLimit]
public var network: LinuxNetwork?
public var rdma: [String: LinuxRdma]?
public var unified: [String: String]?
public init(
devices: [LinuxDeviceCgroup] = [],
memory: LinuxMemory? = nil,
cpu: LinuxCPU? = nil,
pids: LinuxPids? = nil,
blockIO: LinuxBlockIO? = nil,
hugepageLimits: [LinuxHugepageLimit] = [],
network: LinuxNetwork? = nil,
rdma: [String: LinuxRdma]? = nil,
unified: [String: String] = [:]
) {
self.devices = devices
self.memory = memory
self.cpu = cpu
self.pids = pids
self.blockIO = blockIO
self.hugepageLimits = hugepageLimits
self.network = network
self.rdma = rdma
self.unified = unified
}
}
public struct LinuxDevice: Codable, Sendable {
public var path: String
public var type: String
public var major: Int64
public var minor: Int64
public var fileMode: UInt32?
public var uid: UInt32?
public var gid: UInt32?
public init(
path: String,
type: String,
major: Int64,
minor: Int64,
fileMode: UInt32?,
uid: UInt32?,
gid: UInt32?
) {
self.path = path
self.type = type
self.major = major
self.minor = minor
self.fileMode = fileMode
self.uid = uid
self.gid = gid
}
}
public struct LinuxDeviceCgroup: Codable, Sendable {
public var allow: Bool
public var type: String
public var major: Int64?
public var minor: Int64?
public var access: String?
public init(allow: Bool, type: String, major: Int64?, minor: Int64?, access: String?) {
self.allow = allow
self.type = type
self.major = major
self.minor = minor
self.access = access
}
}
public enum LinuxPersonalityDomain: String, Codable, Sendable {
case perLinux = "LINUX"
case perLinux32 = "LINUX32"
}
public struct LinuxPersonality: Codable, Sendable {
public var domain: LinuxPersonalityDomain
public var flags: [String]
public init(domain: LinuxPersonalityDomain, flags: [String]) {
self.domain = domain
self.flags = flags
}
}
public struct LinuxSeccomp: Codable, Sendable {
public var defaultAction: LinuxSeccompAction
public var defaultErrnoRet: UInt?
public var architectures: [Arch]
public var flags: [LinuxSeccompFlag]
public var listenerPath: String
public var listenerMetadata: String
public var syscalls: [LinuxSyscall]
public init(
defaultAction: LinuxSeccompAction,
defaultErrnoRet: UInt?,
architectures: [Arch],
flags: [LinuxSeccompFlag],
listenerPath: String,
listenerMetadata: String,
syscalls: [LinuxSyscall]
) {
self.defaultAction = defaultAction
self.defaultErrnoRet = defaultErrnoRet
self.architectures = architectures
self.flags = flags
self.listenerPath = listenerPath
self.listenerMetadata = listenerMetadata
self.syscalls = syscalls
}
}
public enum LinuxSeccompFlag: String, Codable, Sendable {
case flagLog = "SECCOMP_FILTER_FLAG_LOG"
case flagSpecAllow = "SECCOMP_FILTER_FLAG_SPEC_ALLOW"
case flagWaitKillableRecv = "SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV"
}
public enum Arch: String, Codable, Sendable {
case archX86 = "SCMP_ARCH_X86"
case archX86_64 = "SCMP_ARCH_X86_64"
case archX32 = "SCMP_ARCH_X32"
case archARM = "SCMP_ARCH_ARM"
case archAARCH64 = "SCMP_ARCH_AARCH64"
case archMIPS = "SCMP_ARCH_MIPS"
case archMIPS64 = "SCMP_ARCH_MIPS64"
case archMIPS64N32 = "SCMP_ARCH_MIPS64N32"
case archMIPSEL = "SCMP_ARCH_MIPSEL"
case archMIPSEL64 = "SCMP_ARCH_MIPSEL64"
case archMIPSEL64N32 = "SCMP_ARCH_MIPSEL64N32"
case archPPC = "SCMP_ARCH_PPC"
case archPPC64 = "SCMP_ARCH_PPC64"
case archPPC64LE = "SCMP_ARCH_PPC64LE"
case archS390 = "SCMP_ARCH_S390"
case archS390X = "SCMP_ARCH_S390X"
case archPARISC = "SCMP_ARCH_PARISC"
case archPARISC64 = "SCMP_ARCH_PARISC64"
case archRISCV64 = "SCMP_ARCH_RISCV64"
}
public enum LinuxSeccompAction: String, Codable, Sendable {
case actKill = "SCMP_ACT_KILL"
case actKillProcess = "SCMP_ACT_KILL_PROCESS"
case actKillThread = "SCMP_ACT_KILL_THREAD"
case actTrap = "SCMP_ACT_TRAP"
case actErrno = "SCMP_ACT_ERRNO"
case actTrace = "SCMP_ACT_TRACE"
case actAllow = "SCMP_ACT_ALLOW"
case actLog = "SCMP_ACT_LOG"
case actNotify = "SCMP_ACT_NOTIFY"
}
public enum LinuxSeccompOperator: String, Codable, Sendable {
case opNotEqual = "SCMP_CMP_NE"
case opLessThan = "SCMP_CMP_LT"
case opLessEqual = "SCMP_CMP_LE"
case opEqualTo = "SCMP_CMP_EQ"
case opGreaterEqual = "SCMP_CMP_GE"
case opGreaterThan = "SCMP_CMP_GT"
case opMaskedEqual = "SCMP_CMP_MASKED_EQ"
}
public struct LinuxSeccompArg: Codable, Sendable {
public var index: UInt
public var value: UInt64
public var valueTwo: UInt64
public var op: LinuxSeccompOperator
public init(index: UInt, value: UInt64, valueTwo: UInt64, op: LinuxSeccompOperator) {
self.index = index
self.value = value
self.valueTwo = valueTwo
self.op = op
}
}
public struct LinuxSyscall: Codable, Sendable {
public var names: [String]
public var action: LinuxSeccompAction
public var errnoRet: UInt?
public var args: [LinuxSeccompArg]
public init(
names: [String],
action: LinuxSeccompAction,
errnoRet: UInt?,
args: [LinuxSeccompArg]
) {
self.names = names
self.action = action
self.errnoRet = errnoRet
self.args = args
}
}
+82
View File
@@ -0,0 +1,82 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
public enum ContainerState: String, Codable, Sendable {
case creating
case created
case running
case stopped
}
public struct State: Codable, Sendable {
public init(
version: String,
id: String,
status: ContainerState,
pid: Int,
bundle: String,
annotations: [String: String]?
) {
self.ociVersion = version
self.id = id
self.status = status
self.pid = pid
self.bundle = bundle
self.annotations = annotations
}
public init(instance: State) {
self.ociVersion = instance.ociVersion
self.id = instance.id
self.status = instance.status
self.pid = instance.pid
self.bundle = instance.bundle
self.annotations = instance.annotations
}
public let ociVersion: String
public let id: String
public let status: ContainerState
public let pid: Int
public let bundle: String
public var annotations: [String: String]?
}
public let seccompFdName: String = "seccompFd"
public struct ContainerProcessState: Codable, Sendable {
public init(version: String, fds: [String], pid: Int, metadata: String, state: State) {
self.ociVersion = version
self.fds = fds
self.pid = pid
self.metadata = metadata
self.state = state
}
public init(instance: ContainerProcessState) {
self.ociVersion = instance.ociVersion
self.fds = instance.fds
self.pid = instance.pid
self.metadata = instance.metadata
self.state = instance.state
}
public let ociVersion: String
public var fds: [String]
public let pid: Int
public let metadata: String
public let state: State
}
+34
View File
@@ -0,0 +1,34 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
public struct RuntimeSpecVersion: Sendable {
public let major, minor, patch: Int
public let dev: String
public static let current = RuntimeSpecVersion(
major: 1,
minor: 0,
patch: 2,
dev: "-dev"
)
public init(major: Int, minor: Int, patch: Int, dev: String) {
self.major = major
self.minor = minor
self.patch = patch
self.dev = dev
}
}