//===----------------------------------------------------------------------===// // 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(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 }