From ea5cceb68f32942167d3c3c83a4e7d5a38cf9fdd Mon Sep 17 00:00:00 2001 From: haoruilee Date: Tue, 30 Jun 2026 16:09:17 +0800 Subject: [PATCH 1/6] Verify kernel archive integrity --- Package.swift | 1 + .../System/Kernel/KernelSet.swift | 43 +++++++-- .../System/SystemStart.swift | 13 ++- .../ContainerSystemConfig.swift | 11 ++- .../Client/ClientKernel.swift | 14 ++- .../ContainerAPIService/Client/XPC+.swift | 1 + .../Server/Kernel/KernelHarness.swift | 18 +++- .../Server/Kernel/KernelService.swift | 62 ++++++++++++- .../Subcommands/System/TestKernelSet.swift | 5 + .../KernelServiceTests.swift | 93 +++++++++++++++++++ .../ConfigurationLoaderTests.swift | 21 +++++ docs/command-reference.md | 3 +- docs/container-system-config.md | 11 ++- .../container-system-config-tutorial.md | 1 + 14 files changed, 271 insertions(+), 26 deletions(-) create mode 100644 Tests/ContainerAPIServiceTests/KernelServiceTests.swift diff --git a/Package.swift b/Package.swift index e9d8579d0..4d2ecf175 100644 --- a/Package.swift +++ b/Package.swift @@ -207,6 +207,7 @@ let package = Package( name: "ContainerAPIServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + "ContainerAPIService", "ContainerResource", "ContainerRuntimeLinuxClient", "ContainerRuntimeClient", diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index b4a85d66a..2f080dd0c 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -47,18 +47,25 @@ extension Application { @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file") var tarPath: String? = nil + @Option(name: .long, help: "Expected integrity metadata for the tar archive, for example sha256-") + var integrity: String? = nil + @OptionGroup public var logOptions: Flags.Logging public init() {} public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig() if recommended { + let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig() let url = containerSystemConfig.kernel.url let path: String = containerSystemConfig.kernel.binaryPath log.info("Installing the recommended kernel from \(url)...") - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force) + try await Self.downloadAndInstallWithProgressBar( + tarRemoteURL: url, + kernelFilePath: path, + expectedIntegrity: containerSystemConfig.kernel.integrity, + force: force) return } guard tarPath != nil else { @@ -68,6 +75,9 @@ extension Application { } private func setKernelFromBinary() async throws { + guard integrity == nil else { + throw ArgumentParser.ValidationError("'--integrity' can only be used with '--tar'") + } guard let binaryPath else { throw ArgumentParser.ValidationError("missing argument '--binary'") } @@ -87,13 +97,23 @@ extension Application { let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path let fm = FileManager.default if fm.fileExists(atPath: localTarPath) { - try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force) + try await ClientKernel.installKernelFromTar( + tarFile: localTarPath, + kernelFilePath: binaryPath, + platform: platform, + expectedIntegrity: integrity, + force: force) return } guard let remoteURL = URL(string: tarPath) else { throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, force: force) + try await Self.downloadAndInstallWithProgressBar( + tarRemoteURL: remoteURL, + kernelFilePath: binaryPath, + platform: platform, + expectedIntegrity: integrity, + force: force) } private func getSystemPlatform() throws -> SystemPlatform { @@ -107,7 +127,13 @@ extension Application { } } - static func downloadAndInstallWithProgressBar(tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws { + static func downloadAndInstallWithProgressBar( + tarRemoteURL: URL, + kernelFilePath: String, + platform: SystemPlatform = .current, + expectedIntegrity: String? = nil, + force: Bool + ) async throws { let progressConfig = try ProgressConfig( showTasks: true, totalTasks: 2 @@ -118,7 +144,12 @@ extension Application { } progress.start() try await ClientKernel.installKernelFromTar( - tarFile: tarRemoteURL.absoluteString, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force) + tarFile: tarRemoteURL.absoluteString, + kernelFilePath: kernelFilePath, + platform: platform, + progressUpdate: progress.handler, + expectedIntegrity: expectedIntegrity, + force: force) progress.finish() } diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index 4b3a96243..861ab8de6 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -157,7 +157,10 @@ extension Application { guard await !kernelExists() else { return } - try await installDefaultKernel(kernelURL: containerSystemConfig.kernel.url, kernelBinaryPath: containerSystemConfig.kernel.binaryPath) + try await installDefaultKernel( + kernelURL: containerSystemConfig.kernel.url, + kernelBinaryPath: containerSystemConfig.kernel.binaryPath, + kernelIntegrity: containerSystemConfig.kernel.integrity) } private func installInitialFilesystem(initImage: String) async throws { @@ -171,7 +174,7 @@ extension Application { } } - private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String) async throws { + private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String, kernelIntegrity: String?) async throws { var shouldInstallKernel = false if kernelInstall == nil { print("No default kernel configured.") @@ -191,7 +194,11 @@ extension Application { return } log.info("Installing kernel...") - try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: kernelURL, kernelFilePath: kernelBinaryPath, force: true) + try await KernelSet.downloadAndInstallWithProgressBar( + tarRemoteURL: kernelURL, + kernelFilePath: kernelBinaryPath, + expectedIntegrity: kernelIntegrity, + force: true) } private func initImageExists(containerSystemConfig: ContainerSystemConfig) async -> Bool { diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index f0b9f36f2..a5a99d677 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -168,21 +168,26 @@ final public class KernelConfig: Codable, Sendable { public static let defaultBinaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186" public static let defaultURL: URL = URL(string: "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst")! + public static let defaultIntegrity = "sha256-f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" private enum CodingKeys: String, CodingKey { case binaryPath case url + case integrity } public let binaryPath: String public let url: URL + public let integrity: String? public init( binaryPath: String = defaultBinaryPath, - url: URL = defaultURL + url: URL = defaultURL, + integrity: String? = nil ) { self.binaryPath = binaryPath self.url = url + self.integrity = integrity ?? (url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultIntegrity : nil) } public init(from decoder: any Decoder) throws { @@ -197,6 +202,9 @@ final public class KernelConfig: Codable, Sendable { } else { self.url = Self.defaultURL } + self.integrity = + try container.decodeIfPresent(String.self, forKey: .integrity) + ?? (self.url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultIntegrity : nil) } // JSONEncoder special-cases URL to encode as absoluteString, but third-party @@ -209,6 +217,7 @@ final public class KernelConfig: Codable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(binaryPath, forKey: .binaryPath) try container.encode(url.absoluteString, forKey: .url) + try container.encodeIfPresent(integrity, forKey: .integrity) } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientKernel.swift b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift index 3cac4693c..d21332560 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientKernel.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift @@ -42,15 +42,23 @@ extension ClientKernel { try await client.send(message) } - public static func installKernelFromTar(tarFile: String, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler? = nil, force: Bool) - async throws - { + public static func installKernelFromTar( + tarFile: String, + kernelFilePath: String, + platform: SystemPlatform, + progressUpdate: ProgressUpdateHandler? = nil, + expectedIntegrity: String? = nil, + force: Bool + ) async throws { let client = newClient() let message = XPCMessage(route: .installKernel) message.set(key: .kernelTarURL, value: tarFile) message.set(key: .kernelFilePath, value: kernelFilePath) message.set(key: .kernelForce, value: force) + if let expectedIntegrity { + message.set(key: .kernelIntegrity, value: expectedIntegrity) + } let platformData = try JSONEncoder().encode(platform) message.set(key: .systemPlatform, value: platformData) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 499b82b84..76186f602 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -112,6 +112,7 @@ public enum XPCKeys: String { case kernelFilePath case systemPlatform case kernelForce + case kernelIntegrity /// Init image reference case initImage diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift index d12905778..4a9fa2995 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift @@ -35,6 +35,7 @@ public struct KernelHarness: Sendable { let kernelFilePath = try message.kernelFilePath() let platform = try message.platform() let force = try message.kernelForce() + let expectedIntegrity = message.kernelIntegrity() guard let kernelTarUrl = try message.kernelTarURL() else { // We have been given a path to a kernel binary on disk @@ -47,7 +48,12 @@ public struct KernelHarness: Sendable { let progressUpdateService = ProgressUpdateService(message: message) try await self.service.installKernelFrom( - tar: kernelTarUrl, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler, force: force) + tar: kernelTarUrl, + kernelFilePath: kernelFilePath, + platform: platform, + progressUpdate: progressUpdateService?.handler, + expectedIntegrity: expectedIntegrity, + force: force) return message.reply() } @@ -85,13 +91,17 @@ extension XPCMessage { guard let kernelTarURLString = self.string(key: .kernelTarURL) else { return nil } - guard let k = URL(string: kernelTarURLString) else { - throw ContainerizationError(.invalidArgument, message: "cannot parse URL from \(kernelTarURLString)") + if let k = URL(string: kernelTarURLString), k.scheme != nil { + return k } - return k + return URL(fileURLWithPath: kernelTarURLString) } fileprivate func kernelForce() throws -> Bool { self.bool(key: .kernelForce) } + + fileprivate func kernelIntegrity() -> String? { + self.string(key: .kernelIntegrity) + } } diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift index 84c55a532..c8a47e390 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -19,6 +19,7 @@ import Containerization import ContainerizationArchive import ContainerizationError import ContainerizationExtras +import CryptoKit import Foundation import Logging import TerminalProgress @@ -81,7 +82,14 @@ public actor KernelService { /// Copies a kernel binary from inside of tar file into the managed kernels directory /// as the default kernel for the provided platform. /// The parameter `tar` maybe a location to a local file on disk, or a remote URL. - public func installKernelFrom(tar: URL, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler?, force: Bool) async throws { + public func installKernelFrom( + tar: URL, + kernelFilePath: String, + platform: SystemPlatform, + progressUpdate: ProgressUpdateHandler?, + expectedIntegrity: String? = nil, + force: Bool + ) async throws { log.debug( "KernelService: enter", metadata: [ @@ -114,7 +122,12 @@ public actor KernelService { let taskManager = ProgressTaskCoordinator() let downloadTask = await taskManager.startTask() var tarFile = tar - if !FileManager.default.fileExists(atPath: tar.absoluteString) { + let localTarPath = tar.scheme == nil || tar.isFileURL ? tar.path : nil + let isLocalTar = localTarPath.map { FileManager.default.fileExists(atPath: $0) } ?? false + if isLocalTar, let localTarPath { + tarFile = URL(fileURLWithPath: localTarPath) + } + if !isLocalTar { self.log.debug("KernelService: start download", metadata: ["tar": "\(tar)"]) tarFile = tempDir.appendingPathComponent(tar.lastPathComponent) var downloadProgressUpdate: ProgressUpdateHandler? @@ -125,17 +138,60 @@ public actor KernelService { } await taskManager.finish() + if let expectedIntegrity { + await progressUpdate?([ + .setDescription("Verifying kernel archive") + ]) + try Self.verifyIntegrity(of: tarFile, expected: expectedIntegrity) + } + await progressUpdate?([ .setDescription("Unpacking kernel") ]) let kernelFile = try self.extractFile(tarFile: tarFile, at: kernelFilePath, to: tempDir) try self.installKernel(kernelFile: kernelFile, platform: platform, force: force) - if !FileManager.default.fileExists(atPath: tar.absoluteString) { + if !isLocalTar { try FileManager.default.removeItem(at: tarFile) } } + static func verifyIntegrity(of file: URL, expected: String) throws { + let integrity = try parseIntegrity(expected) + guard integrity.algorithm == "sha256" else { + throw ContainerizationError(.unsupported, message: "unsupported integrity algorithm '\(integrity.algorithm)'") + } + guard integrity.digest.count == 64, integrity.digest.utf8.allSatisfy({ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) }) else { + throw ContainerizationError(.invalidArgument, message: "invalid sha256 integrity value '\(expected)'") + } + + let actualDigest = try sha256Hex(of: file) + guard actualDigest == integrity.digest else { + throw ContainerizationError( + .invalidState, + message: "kernel archive integrity mismatch: expected sha256-\(integrity.digest), got sha256-\(actualDigest)" + ) + } + } + + private static func parseIntegrity(_ expected: String) throws -> (algorithm: String, digest: String) { + let parts = expected.lowercased().split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw ContainerizationError(.invalidArgument, message: "invalid integrity value '\(expected)': expected '-'") + } + return (String(parts[0]), String(parts[1])) + } + + static func sha256Hex(of file: URL) throws -> String { + var hasher = SHA256() + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + while let data = try handle.read(upToCount: Int(1.mib())), !data.isEmpty { + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + private func setDefaultKernel(name: String, platform: SystemPlatform) throws { log.debug( "KernelService: enter", diff --git a/Tests/CLITests/Subcommands/System/TestKernelSet.swift b/Tests/CLITests/Subcommands/System/TestKernelSet.swift index f88e19e17..923b8750d 100644 --- a/Tests/CLITests/Subcommands/System/TestKernelSet.swift +++ b/Tests/CLITests/Subcommands/System/TestKernelSet.swift @@ -25,6 +25,7 @@ import Testing class TestCLIKernelSet: CLITest { let remoteTar = ContainerSystemConfig().kernel.url let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath + let defaultIntegrity = KernelConfig.defaultIntegrity deinit { try? resetDefaultBinary() @@ -85,6 +86,8 @@ class TestCLIKernelSet: CLITest { localTarPath.path, "--binary", symlinkBinaryPath, + "--integrity", + defaultIntegrity, ] try doKernelSet(extraArgs: extraArgs) @@ -100,6 +103,8 @@ class TestCLIKernelSet: CLITest { remoteTar.absoluteString, "--binary", symlinkBinaryPath, + "--integrity", + defaultIntegrity, ] try doKernelSet(extraArgs: extraArgs) diff --git a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift new file mode 100644 index 000000000..8ac5b1869 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container 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 Containerization +import ContainerizationArchive +import ContainerizationError +import Foundation +import Logging +import Testing + +@testable import ContainerAPIService + +struct KernelServiceTests { + @Test func verifyIntegrity() throws { + try withTempFile(contents: "kernel archive") { file in + try KernelService.verifyIntegrity(of: file, expected: "sha256-\(KernelService.sha256Hex(of: file))") + #expect(throws: ContainerizationError.self) { + try KernelService.verifyIntegrity(of: file, expected: "sha256-not-a-digest") + } + #expect(throws: ContainerizationError.self) { + try KernelService.verifyIntegrity(of: file, expected: "sha256:\(String(repeating: "0", count: 64))") + } + #expect(throws: ContainerizationError.self) { + try KernelService.verifyIntegrity(of: file, expected: String(repeating: "0", count: 64)) + } + } + } + + @Test func installKernelFromLocalTarVerifiesDigest() async throws { + try await withTempDir { tempDir in + let kernelPath = "boot/vmlinux" + let kernelData = Data("kernel binary".utf8) + let tarFile = try Self.writeTar( + at: tempDir.appendingPathComponent("kernel.tar"), + path: kernelPath, + data: kernelData) + let service = try KernelService( + log: Logger(label: "com.apple.container.test.kernel-service"), + appRoot: tempDir.appendingPathComponent("app")) + let digest = try KernelService.sha256Hex(of: tarFile) + + try await service.installKernelFrom( + tar: URL(string: tarFile.path)!, + kernelFilePath: kernelPath, + platform: .linuxArm, + progressUpdate: nil, + expectedIntegrity: "sha256-\(digest)", + force: false) + + let kernel = try await service.getDefaultKernel(platform: .linuxArm) + #expect(try Data(contentsOf: kernel.path) == kernelData) + } + } + + private static func writeTar(at tarFile: URL, path: String, data: Data) throws -> URL { + let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: tarFile) + let entry = WriteEntry() + entry.path = path + entry.fileType = .regular + entry.permissions = 0o644 + entry.size = numericCast(data.count) + try archiver.writeEntry(entry: entry, data: data) + try archiver.finishEncoding() + return tarFile + } + + private func withTempFile(contents: String, body: (URL) throws -> Void) throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data(contents.utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + try body(file) + } + + private func withTempDir(body: (URL) async throws -> Void) async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try await body(dir) + } +} diff --git a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift index 5f27694dc..876a5944e 100644 --- a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift +++ b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift @@ -95,6 +95,7 @@ struct ConfigurationLoaderTests { #expect(!config.vminit.image.isEmpty) #expect(!config.kernel.binaryPath.isEmpty) #expect(!config.kernel.url.absoluteString.isEmpty) + #expect(config.kernel.integrity == KernelConfig.defaultIntegrity) #expect(config.network.subnet == nil) #expect(config.network.subnetv6 == nil) #expect(config.registry.domain == "docker.io") @@ -120,6 +121,7 @@ struct ConfigurationLoaderTests { [kernel] binaryPath = "custom/path" url = "https://example.com/kernel.tar" + integrity = "sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" [network] subnet = "10.0.0.1/16" @@ -147,6 +149,7 @@ struct ConfigurationLoaderTests { #expect(config.vminit.image == "custom-init:latest") #expect(config.kernel.binaryPath == "custom/path") #expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar") + #expect(config.kernel.integrity == "sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") let expectedSubnet = try CIDRv4("10.0.0.1/16") let expectedSubnetV6 = try CIDRv6("fd01::/48") #expect(config.network.subnet == expectedSubnet) @@ -173,6 +176,24 @@ struct ConfigurationLoaderTests { } } + @Test func customKernelURLWithoutIntegrityLeavesIntegrityUnset() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [kernel] + url = "https://example.com/custom-kernel.tar" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + #expect(config.kernel.url.absoluteString == "https://example.com/custom-kernel.tar") + #expect(config.kernel.integrity == nil) + } + + let programmaticConfig = KernelConfig(url: URL(string: "https://example.com/custom-kernel.tar")!) + #expect(programmaticConfig.integrity == nil) + } + @Test func unknownKeysIgnored() async throws { try await TemporaryStorage.withTempDir { tempDir in let toml = """ diff --git a/docs/command-reference.md b/docs/command-reference.md index 1492c7f28..01e1088b2 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1530,7 +1530,7 @@ Installs or updates the Linux kernel used by the container runtime on macOS host **Usage** ```bash -container system kernel set [--arch ] [--binary ] [--force] [--recommended] [--tar ] [--debug] +container system kernel set [--arch ] [--binary ] [--force] [--recommended] [--tar ] [--integrity ] [--debug] ``` **Options** @@ -1540,6 +1540,7 @@ container system kernel set [--arch ] [--binary ] [--force] [--rec * `--force`: Overwrites an existing kernel with the same name * `--recommended`: Download and install the recommended kernel as the default (takes precedence over all other flags) * `--tar `: Filesystem path or remote URL to a tar archive containing a kernel file +* `--integrity `: Expected integrity metadata for the tar archive, for example `sha256-` ### `container system property list (ls)` diff --git a/docs/container-system-config.md b/docs/container-system-config.md index 547ae5ff7..4b815c500 100644 --- a/docs/container-system-config.md +++ b/docs/container-system-config.md @@ -15,7 +15,7 @@ Source of truth: [`Sources/ContainerPersistence/ContainerSystemConfig.swift`](.. [build] # builder VM resources and image [container] # default per-container resources [dns] # default DNS domain for DNS resolution on host -[kernel] # guest kernel binary path and download URL +[kernel] # guest kernel binary path, download URL, and digest [network] # default subnets for new networks [registry] # default registry domain [vminit] # default vminitd image to use @@ -54,10 +54,11 @@ Defaults applied when `container run` / `container create` is invoked without `- Guest kernel used when launching container VMs. Defaults change per release as kernels are bumped — check the [source](../Sources/ContainerPersistence/ContainerSystemConfig.swift) for current values. -| Key | Type | Default | Description | -|--------------|----------|--------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| -| `binaryPath` | `String` | `"opt/kata/share/kata-containers/vmlinux-6.18.15-186"` | Path **inside** the downloaded kernel archive that points to the kernel binary. | -| `url` | `URL` | `"https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst"` | Archive to download when no kernel is installed. Encoded and decoded as a plain string in TOML. | +| Key | Type | Default | Description | +|--------------|-----------|--------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| `binaryPath` | `String` | `"opt/kata/share/kata-containers/vmlinux-6.18.15-186"` | Path **inside** the downloaded kernel archive that points to the kernel binary. | +| `url` | `URL` | `"https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst"` | Archive to download when no kernel is installed. Encoded and decoded as a plain string in TOML. | +| `integrity` | `String?` | `"sha256-f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91"` | Expected integrity metadata for the archive, for example `sha256-`. When unset for a custom URL, remote kernel downloads are not verified. | ## `[network]` diff --git a/docs/tutorials/container-system-config-tutorial.md b/docs/tutorials/container-system-config-tutorial.md index df8a4f79e..da4e4f218 100644 --- a/docs/tutorials/container-system-config-tutorial.md +++ b/docs/tutorials/container-system-config-tutorial.md @@ -77,6 +77,7 @@ domain = "test" [kernel] binaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186" url = "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst" +integrity = "sha256-f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" [network] From 911c8fb7861a3dc2167a3ccc09cdc886a5869c21 Mon Sep 17 00:00:00 2001 From: haoruilee Date: Wed, 1 Jul 2026 13:50:18 +0800 Subject: [PATCH 2/6] Use digest for kernel archive verification --- .../System/Kernel/KernelSet.swift | 20 +- .../System/SystemStart.swift | 6 +- .../ContainerSystemConfig.swift | 18 +- .../Client/ClientKernel.swift | 6 +- .../Client/FileDownloader.swift | 234 +++++++++++++++++- .../ContainerAPIService/Client/XPC+.swift | 2 +- .../Server/Kernel/KernelHarness.swift | 8 +- .../Server/Kernel/KernelService.swift | 89 ++++--- .../Subcommands/System/TestKernelSet.swift | 10 +- .../KernelServiceTests.swift | 43 +++- .../ConfigurationLoaderTests.swift | 12 +- docs/command-reference.md | 4 +- docs/container-system-config.md | 2 +- .../container-system-config-tutorial.md | 2 +- 14 files changed, 370 insertions(+), 86 deletions(-) diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index 2f080dd0c..6dcfc5429 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -47,8 +47,8 @@ extension Application { @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file") var tarPath: String? = nil - @Option(name: .long, help: "Expected integrity metadata for the tar archive, for example sha256-") - var integrity: String? = nil + @Option(name: .long, help: "Expected digest for the tar archive, for example sha256:") + var digest: String? = nil @OptionGroup public var logOptions: Flags.Logging @@ -64,7 +64,7 @@ extension Application { try await Self.downloadAndInstallWithProgressBar( tarRemoteURL: url, kernelFilePath: path, - expectedIntegrity: containerSystemConfig.kernel.integrity, + expectedDigest: containerSystemConfig.kernel.digest, force: force) return } @@ -75,8 +75,8 @@ extension Application { } private func setKernelFromBinary() async throws { - guard integrity == nil else { - throw ArgumentParser.ValidationError("'--integrity' can only be used with '--tar'") + guard digest == nil else { + throw ArgumentParser.ValidationError("'--digest' can only be used with '--tar'") } guard let binaryPath else { throw ArgumentParser.ValidationError("missing argument '--binary'") @@ -101,7 +101,7 @@ extension Application { tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, - expectedIntegrity: integrity, + expectedDigest: digest, force: force) return } @@ -112,7 +112,7 @@ extension Application { tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, - expectedIntegrity: integrity, + expectedDigest: digest, force: force) } @@ -131,12 +131,12 @@ extension Application { tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, - expectedIntegrity: String? = nil, + expectedDigest: String? = nil, force: Bool ) async throws { let progressConfig = try ProgressConfig( showTasks: true, - totalTasks: 2 + totalTasks: expectedDigest == nil ? 2 : 3 ) let progress = ProgressBar(config: progressConfig) defer { @@ -148,7 +148,7 @@ extension Application { kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, - expectedIntegrity: expectedIntegrity, + expectedDigest: expectedDigest, force: force) progress.finish() } diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index 861ab8de6..25029bbe6 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -160,7 +160,7 @@ extension Application { try await installDefaultKernel( kernelURL: containerSystemConfig.kernel.url, kernelBinaryPath: containerSystemConfig.kernel.binaryPath, - kernelIntegrity: containerSystemConfig.kernel.integrity) + kernelDigest: containerSystemConfig.kernel.digest) } private func installInitialFilesystem(initImage: String) async throws { @@ -174,7 +174,7 @@ extension Application { } } - private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String, kernelIntegrity: String?) async throws { + private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String, kernelDigest: String?) async throws { var shouldInstallKernel = false if kernelInstall == nil { print("No default kernel configured.") @@ -197,7 +197,7 @@ extension Application { try await KernelSet.downloadAndInstallWithProgressBar( tarRemoteURL: kernelURL, kernelFilePath: kernelBinaryPath, - expectedIntegrity: kernelIntegrity, + expectedDigest: kernelDigest, force: true) } diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index a5a99d677..9bf4c2377 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -168,26 +168,26 @@ final public class KernelConfig: Codable, Sendable { public static let defaultBinaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186" public static let defaultURL: URL = URL(string: "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst")! - public static let defaultIntegrity = "sha256-f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" + public static let defaultDigest = "sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" private enum CodingKeys: String, CodingKey { case binaryPath case url - case integrity + case digest } public let binaryPath: String public let url: URL - public let integrity: String? + public let digest: String? public init( binaryPath: String = defaultBinaryPath, url: URL = defaultURL, - integrity: String? = nil + digest: String? = nil ) { self.binaryPath = binaryPath self.url = url - self.integrity = integrity ?? (url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultIntegrity : nil) + self.digest = digest ?? (url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultDigest : nil) } public init(from decoder: any Decoder) throws { @@ -202,9 +202,9 @@ final public class KernelConfig: Codable, Sendable { } else { self.url = Self.defaultURL } - self.integrity = - try container.decodeIfPresent(String.self, forKey: .integrity) - ?? (self.url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultIntegrity : nil) + self.digest = + try container.decodeIfPresent(String.self, forKey: .digest) + ?? (self.url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultDigest : nil) } // JSONEncoder special-cases URL to encode as absoluteString, but third-party @@ -217,7 +217,7 @@ final public class KernelConfig: Codable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(binaryPath, forKey: .binaryPath) try container.encode(url.absoluteString, forKey: .url) - try container.encodeIfPresent(integrity, forKey: .integrity) + try container.encodeIfPresent(digest, forKey: .digest) } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientKernel.swift b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift index d21332560..44e79e062 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientKernel.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift @@ -47,7 +47,7 @@ extension ClientKernel { kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler? = nil, - expectedIntegrity: String? = nil, + expectedDigest: String? = nil, force: Bool ) async throws { let client = newClient() @@ -56,8 +56,8 @@ extension ClientKernel { message.set(key: .kernelTarURL, value: tarFile) message.set(key: .kernelFilePath, value: kernelFilePath) message.set(key: .kernelForce, value: force) - if let expectedIntegrity { - message.set(key: .kernelIntegrity, value: expectedIntegrity) + if let expectedDigest { + message.set(key: .kernelDigest, value: expectedDigest) } let platformData = try JSONEncoder().encode(platform) diff --git a/Sources/Services/ContainerAPIService/Client/FileDownloader.swift b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift index 83aabfc51..2e54d3a81 100644 --- a/Sources/Services/ContainerAPIService/Client/FileDownloader.swift +++ b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift @@ -17,7 +17,11 @@ import AsyncHTTPClient import ContainerizationError import ContainerizationExtras +import CryptoKit import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix import TerminalProgress public struct FileDownloader { @@ -28,13 +32,11 @@ public struct FileDownloader { path: destination.path(), reportHead: { let expectedSizeString = $0.headers["Content-Length"].first ?? "" - if let expectedSize = Int64(expectedSizeString) { - if let progressUpdate { - Task { - await progressUpdate([ - .addTotalSize(expectedSize) - ]) - } + if let expectedSize = Int64(expectedSizeString), let progressUpdate { + Task { + await progressUpdate([ + .addTotalSize(expectedSize) + ]) } } }, @@ -59,6 +61,59 @@ public struct FileDownloader { try await client.shutdown() } + public static func downloadFile( + url: URL, + to destination: URL, + progressUpdate: ProgressUpdateHandler? = nil, + computingSHA256: Bool + ) async throws -> String? { + guard computingSHA256 else { + try await downloadFile(url: url, to: destination, progressUpdate: progressUpdate) + return nil + } + + let request = try HTTPClient.Request(url: url) + let fileIOThreadPool = NIOThreadPool(numberOfThreads: 1) + fileIOThreadPool.start() + + let delegate = try HashingFileDownloadDelegate( + path: destination.path(), + pool: fileIOThreadPool, + computingSHA256: computingSHA256, + reportHead: { + let expectedSizeString = $0.headers["Content-Length"].first ?? "" + if let expectedSize = Int64(expectedSizeString), let progressUpdate { + Task { + await progressUpdate([ + .addTotalSize(expectedSize) + ]) + } + } + }, + reportProgress: { + let receivedBytes = Int64($0.receivedBytes) + if let progressUpdate { + Task { + await progressUpdate([ + .setSize(receivedBytes) + ]) + } + } + }) + + let client = FileDownloader.createClient(url: url) + do { + let response = try await client.execute(request: request, delegate: delegate).get() + try await client.shutdown() + await FileDownloader.shutdown(fileIOThreadPool) + return response.sha256Digest + } catch { + try? await client.shutdown() + await FileDownloader.shutdown(fileIOThreadPool) + throw error + } + } + private static func createClient(url: URL) -> HTTPClient { var httpConfiguration = HTTPClient.Configuration() // for large file downloads we keep a generous connect timeout, and @@ -76,4 +131,169 @@ public struct FileDownloader { return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) } + + private static func shutdown(_ threadPool: NIOThreadPool) async { + await withCheckedContinuation { continuation in + threadPool.shutdownGracefully { _ in + continuation.resume() + } + } + } +} + +// Mirrors AsyncHTTPClient's file download delegate while updating an optional +// SHA-256 hasher from the same response chunks that are written to disk. +private final class HashingFileDownloadDelegate: @unchecked Sendable, HTTPClientResponseDelegate { + struct Progress: Sendable { + var totalBytes: Int? + var receivedBytes: Int + } + + struct Response: Sendable { + var progress: Progress + var sha256Digest: String? + } + + private struct State { + var progress = Progress(totalBytes: nil, receivedBytes: 0) + var fileHandleFuture: EventLoopFuture? + var writeFuture: EventLoopFuture? + var sha256: SHA256? + } + + private let filePath: String + private let fileIOThreadPool: NIOThreadPool + private let reportHead: (@Sendable (HTTPResponseHead) -> Void)? + private let reportProgress: (@Sendable (Progress) -> Void)? + private let lock = NSLock() + private var state: State + + init( + path: String, + pool: NIOThreadPool, + computingSHA256: Bool, + reportHead: (@Sendable (HTTPResponseHead) -> Void)? = nil, + reportProgress: (@Sendable (Progress) -> Void)? = nil + ) throws { + self.filePath = path + self.fileIOThreadPool = pool + self.reportHead = reportHead + self.reportProgress = reportProgress + self.state = State(sha256: computingSHA256 ? SHA256() : nil) + } + + func didReceiveHead(task: HTTPClient.Task, _ head: HTTPResponseHead) -> EventLoopFuture { + withState { + if let totalBytesString = head.headers.first(name: "Content-Length"), + let totalBytes = Int(totalBytesString) + { + $0.progress.totalBytes = totalBytes + } + } + reportHead?(head) + return task.eventLoop.makeSucceededFuture(()) + } + + func didReceiveBodyPart(task: HTTPClient.Task, _ buffer: ByteBuffer) -> EventLoopFuture { + buffer.withUnsafeReadableBytes { readableBytes in + withState { + $0.sha256?.update(bufferPointer: readableBytes) + } + } + + let (progress, io) = withState { state in + let io = NonBlockingFileIO(threadPool: fileIOThreadPool) + state.progress.receivedBytes += buffer.readableBytes + return (state.progress, io) + } + reportProgress?(progress) + + let writeFuture = withState { state in + let writeFuture: EventLoopFuture + if let fileHandleFuture = state.fileHandleFuture { + writeFuture = fileHandleFuture.flatMap { + io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop) + } + } else { + let fileHandleFuture = io.openFile( + _deprecatedPath: filePath, + mode: .write, + flags: .allowFileCreation(), + eventLoop: task.eventLoop + ) + state.fileHandleFuture = fileHandleFuture + writeFuture = fileHandleFuture.flatMap { + io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop) + } + } + + state.writeFuture = writeFuture + return writeFuture + } + + return writeFuture + } + + func didReceiveError(task: HTTPClient.Task, _ error: Error) { + finalize() + } + + func didFinishRequest(task: HTTPClient.Task) throws -> Response { + finalize() + return withState { state in + let digest = state.sha256?.finalize().map { String(format: "%02x", $0) }.joined() + return Response(progress: state.progress, sha256Digest: digest) + } + } + + private func close(fileHandle: NIOFileHandle) { + try! fileHandle.close() + withState { + $0.fileHandleFuture = nil + } + } + + private func finalize() { + enum Finalize { + case writeFuture(EventLoopFuture) + case fileHandleFuture(EventLoopFuture) + case none + } + + let finalize = withState { state in + if let writeFuture = state.writeFuture { + return Finalize.writeFuture(writeFuture) + } else if let fileHandleFuture = state.fileHandleFuture { + return Finalize.fileHandleFuture(fileHandleFuture) + } else { + return Finalize.none + } + } + + switch finalize { + case .writeFuture(let future): + future.whenComplete { _ in + let fileHandleFuture = self.withState { state in + let future = state.fileHandleFuture + state.fileHandleFuture = nil + state.writeFuture = nil + return future + } + + fileHandleFuture?.whenSuccess { + self.close(fileHandle: $0) + } + } + case .fileHandleFuture(let future): + future.whenSuccess { self.close(fileHandle: $0) } + case .none: + () + } + } + + private func withState(_ body: (inout State) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body(&state) + } } diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 76186f602..a4d5aebd3 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -112,7 +112,7 @@ public enum XPCKeys: String { case kernelFilePath case systemPlatform case kernelForce - case kernelIntegrity + case kernelDigest /// Init image reference case initImage diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift index 4a9fa2995..8808d3568 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift @@ -35,7 +35,7 @@ public struct KernelHarness: Sendable { let kernelFilePath = try message.kernelFilePath() let platform = try message.platform() let force = try message.kernelForce() - let expectedIntegrity = message.kernelIntegrity() + let expectedDigest = message.kernelDigest() guard let kernelTarUrl = try message.kernelTarURL() else { // We have been given a path to a kernel binary on disk @@ -52,7 +52,7 @@ public struct KernelHarness: Sendable { kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler, - expectedIntegrity: expectedIntegrity, + expectedDigest: expectedDigest, force: force) return message.reply() } @@ -101,7 +101,7 @@ extension XPCMessage { self.bool(key: .kernelForce) } - fileprivate func kernelIntegrity() -> String? { - self.string(key: .kernelIntegrity) + fileprivate func kernelDigest() -> String? { + self.string(key: .kernelDigest) } } diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift index c8a47e390..67b0e92e3 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -30,6 +30,11 @@ public actor KernelService { private let log: Logger private let kernelDirectory: URL + private struct ExpectedDigest { + let algorithm: String + let hex: String + } + public init(log: Logger, appRoot: URL) throws { self.log = log self.kernelDirectory = appRoot.appending(path: "kernels") @@ -87,7 +92,7 @@ public actor KernelService { kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler?, - expectedIntegrity: String? = nil, + expectedDigest: String? = nil, force: Bool ) async throws { log.debug( @@ -111,38 +116,55 @@ public actor KernelService { ) } + let expectedDigest = try expectedDigest.map(Self.parseExpectedDigest) + var tarFile = tar + let localTarPath = tar.scheme == nil || tar.isFileURL ? tar.path : nil + let isLocalTar = localTarPath.map { FileManager.default.fileExists(atPath: $0) } ?? false + if isLocalTar, let localTarPath { + tarFile = URL(fileURLWithPath: localTarPath) + } + let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } await progressUpdate?([ - .setDescription("Downloading kernel") + .setDescription(isLocalTar ? "Reading kernel archive" : "Downloading kernel") ]) - let taskManager = ProgressTaskCoordinator() - let downloadTask = await taskManager.startTask() - var tarFile = tar - let localTarPath = tar.scheme == nil || tar.isFileURL ? tar.path : nil - let isLocalTar = localTarPath.map { FileManager.default.fileExists(atPath: $0) } ?? false - if isLocalTar, let localTarPath { - tarFile = URL(fileURLWithPath: localTarPath) - } + var downloadedSHA256Digest: String? if !isLocalTar { + let taskManager = ProgressTaskCoordinator() + let downloadTask = await taskManager.startTask() self.log.debug("KernelService: start download", metadata: ["tar": "\(tar)"]) tarFile = tempDir.appendingPathComponent(tar.lastPathComponent) var downloadProgressUpdate: ProgressUpdateHandler? if let progressUpdate { downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) } - try await ContainerAPIClient.FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) + downloadedSHA256Digest = try await ContainerAPIClient.FileDownloader.downloadFile( + url: tar, + to: tarFile, + progressUpdate: downloadProgressUpdate, + computingSHA256: expectedDigest != nil) + await taskManager.finish() } - await taskManager.finish() + await progressUpdate?([ + .addTasks(1) + ]) - if let expectedIntegrity { + if let expectedDigest { await progressUpdate?([ .setDescription("Verifying kernel archive") ]) - try Self.verifyIntegrity(of: tarFile, expected: expectedIntegrity) + if let downloadedSHA256Digest { + try Self.verifyDigest(actualSHA256Hex: downloadedSHA256Digest, expected: expectedDigest) + } else { + try Self.verifyDigest(of: tarFile, expected: expectedDigest) + } + await progressUpdate?([ + .addTasks(1) + ]) } await progressUpdate?([ @@ -150,36 +172,47 @@ public actor KernelService { ]) let kernelFile = try self.extractFile(tarFile: tarFile, at: kernelFilePath, to: tempDir) try self.installKernel(kernelFile: kernelFile, platform: platform, force: force) + await progressUpdate?([ + .addTasks(1) + ]) if !isLocalTar { try FileManager.default.removeItem(at: tarFile) } } - static func verifyIntegrity(of file: URL, expected: String) throws { - let integrity = try parseIntegrity(expected) - guard integrity.algorithm == "sha256" else { - throw ContainerizationError(.unsupported, message: "unsupported integrity algorithm '\(integrity.algorithm)'") - } - guard integrity.digest.count == 64, integrity.digest.utf8.allSatisfy({ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) }) else { - throw ContainerizationError(.invalidArgument, message: "invalid sha256 integrity value '\(expected)'") - } + static func verifyDigest(of file: URL, expected: String) throws { + let expectedDigest = try parseExpectedDigest(expected) + try verifyDigest(of: file, expected: expectedDigest) + } + private static func verifyDigest(of file: URL, expected: ExpectedDigest) throws { let actualDigest = try sha256Hex(of: file) - guard actualDigest == integrity.digest else { + try verifyDigest(actualSHA256Hex: actualDigest, expected: expected) + } + + private static func verifyDigest(actualSHA256Hex actualDigest: String, expected: ExpectedDigest) throws { + guard actualDigest == expected.hex else { throw ContainerizationError( .invalidState, - message: "kernel archive integrity mismatch: expected sha256-\(integrity.digest), got sha256-\(actualDigest)" + message: "kernel archive digest mismatch: expected sha256:\(expected.hex), got sha256:\(actualDigest)" ) } } - private static func parseIntegrity(_ expected: String) throws -> (algorithm: String, digest: String) { - let parts = expected.lowercased().split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + private static func parseExpectedDigest(_ expected: String) throws -> ExpectedDigest { + let parts = expected.lowercased().split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { - throw ContainerizationError(.invalidArgument, message: "invalid integrity value '\(expected)': expected '-'") + throw ContainerizationError(.invalidArgument, message: "invalid digest value '\(expected)': expected ':'") + } + let digest = ExpectedDigest(algorithm: String(parts[0]), hex: String(parts[1])) + guard digest.algorithm == "sha256" else { + throw ContainerizationError(.unsupported, message: "unsupported digest algorithm '\(digest.algorithm)'") + } + guard digest.hex.count == 64, digest.hex.utf8.allSatisfy({ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) }) else { + throw ContainerizationError(.invalidArgument, message: "invalid sha256 digest value '\(expected)'") } - return (String(parts[0]), String(parts[1])) + return digest } static func sha256Hex(of file: URL) throws -> String { diff --git a/Tests/CLITests/Subcommands/System/TestKernelSet.swift b/Tests/CLITests/Subcommands/System/TestKernelSet.swift index 923b8750d..075828207 100644 --- a/Tests/CLITests/Subcommands/System/TestKernelSet.swift +++ b/Tests/CLITests/Subcommands/System/TestKernelSet.swift @@ -25,7 +25,7 @@ import Testing class TestCLIKernelSet: CLITest { let remoteTar = ContainerSystemConfig().kernel.url let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath - let defaultIntegrity = KernelConfig.defaultIntegrity + let defaultDigest = KernelConfig.defaultDigest deinit { try? resetDefaultBinary() @@ -86,8 +86,8 @@ class TestCLIKernelSet: CLITest { localTarPath.path, "--binary", symlinkBinaryPath, - "--integrity", - defaultIntegrity, + "--digest", + defaultDigest, ] try doKernelSet(extraArgs: extraArgs) @@ -103,8 +103,8 @@ class TestCLIKernelSet: CLITest { remoteTar.absoluteString, "--binary", symlinkBinaryPath, - "--integrity", - defaultIntegrity, + "--digest", + defaultDigest, ] try doKernelSet(extraArgs: extraArgs) diff --git a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift index 8ac5b1869..8f09e0982 100644 --- a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift +++ b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift @@ -24,17 +24,20 @@ import Testing @testable import ContainerAPIService struct KernelServiceTests { - @Test func verifyIntegrity() throws { + @Test func verifyDigest() throws { try withTempFile(contents: "kernel archive") { file in - try KernelService.verifyIntegrity(of: file, expected: "sha256-\(KernelService.sha256Hex(of: file))") + try KernelService.verifyDigest(of: file, expected: "sha256:\(KernelService.sha256Hex(of: file))") #expect(throws: ContainerizationError.self) { - try KernelService.verifyIntegrity(of: file, expected: "sha256-not-a-digest") + try KernelService.verifyDigest(of: file, expected: "sha256-not-a-digest") } #expect(throws: ContainerizationError.self) { - try KernelService.verifyIntegrity(of: file, expected: "sha256:\(String(repeating: "0", count: 64))") + try KernelService.verifyDigest(of: file, expected: "sha256:not-a-digest") } #expect(throws: ContainerizationError.self) { - try KernelService.verifyIntegrity(of: file, expected: String(repeating: "0", count: 64)) + try KernelService.verifyDigest(of: file, expected: String(repeating: "0", count: 64)) + } + #expect(throws: ContainerizationError.self) { + try KernelService.verifyDigest(of: file, expected: "sha256:\(String(repeating: "0", count: 64))") } } } @@ -57,7 +60,7 @@ struct KernelServiceTests { kernelFilePath: kernelPath, platform: .linuxArm, progressUpdate: nil, - expectedIntegrity: "sha256-\(digest)", + expectedDigest: "sha256:\(digest)", force: false) let kernel = try await service.getDefaultKernel(platform: .linuxArm) @@ -65,6 +68,34 @@ struct KernelServiceTests { } } + @Test func installKernelFromLocalTarRejectsDigestMismatchWithoutInstalling() async throws { + try await withTempDir { tempDir in + let kernelPath = "boot/vmlinux" + let kernelData = Data("kernel binary".utf8) + let tarFile = try Self.writeTar( + at: tempDir.appendingPathComponent("kernel.tar"), + path: kernelPath, + data: kernelData) + let service = try KernelService( + log: Logger(label: "com.apple.container.test.kernel-service"), + appRoot: tempDir.appendingPathComponent("app")) + let wrongDigest = String(repeating: "0", count: 64) + + await #expect(throws: ContainerizationError.self) { + try await service.installKernelFrom( + tar: URL(fileURLWithPath: tarFile.path), + kernelFilePath: kernelPath, + platform: .linuxArm, + progressUpdate: nil, + expectedDigest: "sha256:\(wrongDigest)", + force: false) + } + await #expect(throws: ContainerizationError.self) { + _ = try await service.getDefaultKernel(platform: .linuxArm) + } + } + } + private static func writeTar(at tarFile: URL, path: String, data: Data) throws -> URL { let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: tarFile) let entry = WriteEntry() diff --git a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift index 876a5944e..d2811bfb5 100644 --- a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift +++ b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift @@ -95,7 +95,7 @@ struct ConfigurationLoaderTests { #expect(!config.vminit.image.isEmpty) #expect(!config.kernel.binaryPath.isEmpty) #expect(!config.kernel.url.absoluteString.isEmpty) - #expect(config.kernel.integrity == KernelConfig.defaultIntegrity) + #expect(config.kernel.digest == KernelConfig.defaultDigest) #expect(config.network.subnet == nil) #expect(config.network.subnetv6 == nil) #expect(config.registry.domain == "docker.io") @@ -121,7 +121,7 @@ struct ConfigurationLoaderTests { [kernel] binaryPath = "custom/path" url = "https://example.com/kernel.tar" - integrity = "sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" [network] subnet = "10.0.0.1/16" @@ -149,7 +149,7 @@ struct ConfigurationLoaderTests { #expect(config.vminit.image == "custom-init:latest") #expect(config.kernel.binaryPath == "custom/path") #expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar") - #expect(config.kernel.integrity == "sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + #expect(config.kernel.digest == "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") let expectedSubnet = try CIDRv4("10.0.0.1/16") let expectedSubnetV6 = try CIDRv6("fd01::/48") #expect(config.network.subnet == expectedSubnet) @@ -176,7 +176,7 @@ struct ConfigurationLoaderTests { } } - @Test func customKernelURLWithoutIntegrityLeavesIntegrityUnset() async throws { + @Test func customKernelURLWithoutDigestLeavesDigestUnset() async throws { try await TemporaryStorage.withTempDir { tempDir in let toml = """ [kernel] @@ -187,11 +187,11 @@ struct ConfigurationLoaderTests { let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) #expect(config.kernel.url.absoluteString == "https://example.com/custom-kernel.tar") - #expect(config.kernel.integrity == nil) + #expect(config.kernel.digest == nil) } let programmaticConfig = KernelConfig(url: URL(string: "https://example.com/custom-kernel.tar")!) - #expect(programmaticConfig.integrity == nil) + #expect(programmaticConfig.digest == nil) } @Test func unknownKeysIgnored() async throws { diff --git a/docs/command-reference.md b/docs/command-reference.md index 01e1088b2..e0443cdd8 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1530,7 +1530,7 @@ Installs or updates the Linux kernel used by the container runtime on macOS host **Usage** ```bash -container system kernel set [--arch ] [--binary ] [--force] [--recommended] [--tar ] [--integrity ] [--debug] +container system kernel set [--arch ] [--binary ] [--force] [--recommended] [--tar ] [--digest ] [--debug] ``` **Options** @@ -1540,7 +1540,7 @@ container system kernel set [--arch ] [--binary ] [--force] [--rec * `--force`: Overwrites an existing kernel with the same name * `--recommended`: Download and install the recommended kernel as the default (takes precedence over all other flags) * `--tar `: Filesystem path or remote URL to a tar archive containing a kernel file -* `--integrity `: Expected integrity metadata for the tar archive, for example `sha256-` +* `--digest `: Expected digest for the tar archive, for example `sha256:` ### `container system property list (ls)` diff --git a/docs/container-system-config.md b/docs/container-system-config.md index 4b815c500..8bbf9cbda 100644 --- a/docs/container-system-config.md +++ b/docs/container-system-config.md @@ -58,7 +58,7 @@ Guest kernel used when launching container VMs. Defaults change per release as k |--------------|-----------|--------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| | `binaryPath` | `String` | `"opt/kata/share/kata-containers/vmlinux-6.18.15-186"` | Path **inside** the downloaded kernel archive that points to the kernel binary. | | `url` | `URL` | `"https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst"` | Archive to download when no kernel is installed. Encoded and decoded as a plain string in TOML. | -| `integrity` | `String?` | `"sha256-f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91"` | Expected integrity metadata for the archive, for example `sha256-`. When unset for a custom URL, remote kernel downloads are not verified. | +| `digest` | `String?` | `"sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91"` | Expected digest for the archive, for example `sha256:`. When unset for a custom URL, remote kernel downloads are not verified. | ## `[network]` diff --git a/docs/tutorials/container-system-config-tutorial.md b/docs/tutorials/container-system-config-tutorial.md index da4e4f218..b35b496a0 100644 --- a/docs/tutorials/container-system-config-tutorial.md +++ b/docs/tutorials/container-system-config-tutorial.md @@ -77,7 +77,7 @@ domain = "test" [kernel] binaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186" url = "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst" -integrity = "sha256-f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" +digest = "sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" [network] From 04169a01c8d2b43af7062a33793313c8ad3148ec Mon Sep 17 00:00:00 2001 From: haoruilee Date: Thu, 2 Jul 2026 18:31:42 +0800 Subject: [PATCH 3/6] Require digest for remote kernel archives --- .../System/Kernel/KernelSet.swift | 9 ++-- .../System/SystemStart.swift | 2 +- .../ConfigurationLoader.swift | 39 +++++++++++++++++- .../ContainerSystemConfig.swift | 41 ++++++++++++------- .../Server/Kernel/KernelService.swift | 8 +++- .../KernelServiceTests.swift | 18 ++++++++ .../ConfigurationLoaderTests.swift | 39 +++++++++++++++--- docs/command-reference.md | 2 +- docs/container-system-config.md | 2 +- docs/how-to.md | 5 ++- 10 files changed, 135 insertions(+), 30 deletions(-) diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index 6dcfc5429..5cde09362 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -47,7 +47,7 @@ extension Application { @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file") var tarPath: String? = nil - @Option(name: .long, help: "Expected digest for the tar archive, for example sha256:") + @Option(name: .long, help: "Expected digest for the tar archive, for example sha256:. Required when --tar is a remote URL.") var digest: String? = nil @OptionGroup @@ -108,6 +108,9 @@ extension Application { guard let remoteURL = URL(string: tarPath) else { throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") } + guard let digest else { + throw ArgumentParser.ValidationError("'--digest' is required when '--tar' is a remote URL") + } try await Self.downloadAndInstallWithProgressBar( tarRemoteURL: remoteURL, kernelFilePath: binaryPath, @@ -131,12 +134,12 @@ extension Application { tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, - expectedDigest: String? = nil, + expectedDigest: String, force: Bool ) async throws { let progressConfig = try ProgressConfig( showTasks: true, - totalTasks: expectedDigest == nil ? 2 : 3 + totalTasks: 3 ) let progress = ProgressBar(config: progressConfig) defer { diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index 3ce4657ac..b91eb7964 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -174,7 +174,7 @@ extension Application { } } - private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String, kernelDigest: String?) async throws { + private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String, kernelDigest: String) async throws { var shouldInstallKernel = false if kernelInstall == nil { print("No default kernel configured.") diff --git a/Sources/ContainerPersistence/ConfigurationLoader.swift b/Sources/ContainerPersistence/ConfigurationLoader.swift index 760fd46f2..c0ca03d0b 100644 --- a/Sources/ContainerPersistence/ConfigurationLoader.swift +++ b/Sources/ContainerPersistence/ConfigurationLoader.swift @@ -104,6 +104,7 @@ public enum ConfigurationLoader { try await loadAndDecode( ContainerSystemConfig.self, configurationFiles: configurationFiles, + validateKernelConfigLayers: true, decodeErrorContext: "failed to decode configuration" ) } @@ -148,6 +149,7 @@ public enum ConfigurationLoader { _ type: T.Type, configurationFiles: [FilePath], scope: ConfigKey? = nil, + validateKernelConfigLayers: Bool = false, decodeErrorContext: String ) async throws -> T { let paths = configurationFiles.isEmpty ? defaultConfigFiles() : configurationFiles @@ -158,14 +160,28 @@ public enum ConfigurationLoader { var providers: [FileProvider] = [] for path in paths { + let provider: FileProvider do { - try providers.append(await FileProvider(filePath: path, allowMissing: true)) + provider = try await FileProvider(filePath: path, allowMissing: true) } catch { throw ContainerizationError( .invalidArgument, message: "failed to load configuration from '\(path)': \(error)" ) } + if validateKernelConfigLayers { + do { + try validateKernelArchiveDigest(in: provider, path: path) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to validate kernel configuration from '\(path)': \(error)" + ) + } + } + providers.append(provider) } let reader = ConfigReader(providers: providers) @@ -180,6 +196,27 @@ public enum ConfigurationLoader { } } + private static func validateKernelArchiveDigest(in provider: FileProvider, path: FilePath) throws { + let urlResult = try provider.value(forKey: AbsoluteConfigKey(ConfigKey("kernel.url")), type: .string) + guard let urlValue = urlResult.value else { + return + } + guard case .string(let urlString) = urlValue.content else { + return + } + guard urlString != KernelConfig.defaultURL.absoluteString else { + return + } + + let digestResult = try provider.value(forKey: AbsoluteConfigKey(ConfigKey("kernel.digest")), type: .string) + guard digestResult.value != nil else { + throw ContainerizationError( + .invalidArgument, + message: "kernel.digest is required in '\(path)' when kernel.url configures a custom archive" + ) + } + } + /// Copies the user's runtime configuration into the app-root as a read-only snapshot. /// /// If `source` does not exist, this is a no-op. Otherwise, any existing destination diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index 7e2fffe0b..d8c9e52ae 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -178,16 +178,18 @@ final public class KernelConfig: Codable, Sendable { public let binaryPath: String public let url: URL - public let digest: String? + public let digest: String - public init( - binaryPath: String = defaultBinaryPath, - url: URL = defaultURL, - digest: String? = nil - ) { + public init(binaryPath: String = defaultBinaryPath) { + self.binaryPath = binaryPath + self.url = Self.defaultURL + self.digest = Self.defaultDigest + } + + public init(binaryPath: String = defaultBinaryPath, url: URL, digest: String) { self.binaryPath = binaryPath self.url = url - self.digest = digest ?? (url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultDigest : nil) + self.digest = digest } public init(from decoder: any Decoder) throws { @@ -195,16 +197,27 @@ final public class KernelConfig: Codable, Sendable { self.binaryPath = try container.decodeIfPresent(String.self, forKey: .binaryPath) ?? Self.defaultBinaryPath - if let urlString = try container.decodeIfPresent(String.self, forKey: .url), - let parsed = URL(string: urlString) - { + if let urlString = try container.decodeIfPresent(String.self, forKey: .url) { + guard let parsed = URL(string: urlString) else { + throw DecodingError.dataCorruptedError( + forKey: .url, + in: container, + debugDescription: "invalid kernel URL '\(urlString)'") + } self.url = parsed } else { self.url = Self.defaultURL } - self.digest = - try container.decodeIfPresent(String.self, forKey: .digest) - ?? (self.url.absoluteString == Self.defaultURL.absoluteString ? Self.defaultDigest : nil) + if let digest = try container.decodeIfPresent(String.self, forKey: .digest) { + self.digest = digest + } else if self.url.absoluteString == Self.defaultURL.absoluteString { + self.digest = Self.defaultDigest + } else { + throw DecodingError.dataCorruptedError( + forKey: .digest, + in: container, + debugDescription: "kernel.digest is required when kernel.url is not the default URL") + } } // JSONEncoder special-cases URL to encode as absoluteString, but third-party @@ -217,7 +230,7 @@ final public class KernelConfig: Codable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(binaryPath, forKey: .binaryPath) try container.encode(url.absoluteString, forKey: .url) - try container.encodeIfPresent(digest, forKey: .digest) + try container.encode(digest, forKey: .digest) } } diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift index 67b0e92e3..05359355e 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -116,13 +116,19 @@ public actor KernelService { ) } - let expectedDigest = try expectedDigest.map(Self.parseExpectedDigest) var tarFile = tar let localTarPath = tar.scheme == nil || tar.isFileURL ? tar.path : nil let isLocalTar = localTarPath.map { FileManager.default.fileExists(atPath: $0) } ?? false if isLocalTar, let localTarPath { tarFile = URL(fileURLWithPath: localTarPath) } + guard isLocalTar || expectedDigest != nil else { + throw ContainerizationError( + .invalidArgument, + message: "kernel archive digest is required for remote URL '\(tar)'" + ) + } + let expectedDigest = try expectedDigest.map(Self.parseExpectedDigest) let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { diff --git a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift index 8f09e0982..8ab5b11d4 100644 --- a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift +++ b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift @@ -96,6 +96,24 @@ struct KernelServiceTests { } } + @Test func installKernelFromRemoteTarRequiresDigest() async throws { + try await withTempDir { tempDir in + let service = try KernelService( + log: Logger(label: "com.apple.container.test.kernel-service"), + appRoot: tempDir.appendingPathComponent("app")) + + await #expect(throws: ContainerizationError.self) { + try await service.installKernelFrom( + tar: URL(string: "https://example.com/kernel.tar")!, + kernelFilePath: "boot/vmlinux", + platform: .linuxArm, + progressUpdate: nil, + expectedDigest: nil, + force: false) + } + } + } + private static func writeTar(at tarFile: URL, path: String, data: Data) throws -> URL { let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: tarFile) let entry = WriteEntry() diff --git a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift index d2811bfb5..8a453d33e 100644 --- a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift +++ b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift @@ -176,7 +176,7 @@ struct ConfigurationLoaderTests { } } - @Test func customKernelURLWithoutDigestLeavesDigestUnset() async throws { + @Test func customKernelURLWithoutDigestThrows() async throws { try await TemporaryStorage.withTempDir { tempDir in let toml = """ [kernel] @@ -185,13 +185,40 @@ struct ConfigurationLoaderTests { let tmpFile = tempDir.appending("test.toml") try Self.writeToml(toml, to: tmpFile) - let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) - #expect(config.kernel.url.absoluteString == "https://example.com/custom-kernel.tar") - #expect(config.kernel.digest == nil) + await #expect(throws: (any Error).self) { + let _: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + } + } + } + + @Test func layeredCustomKernelURLRequiresDigestInSameFile() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let userFile = tempDir.appending("user.toml") + let systemFile = tempDir.appending("system.toml") + + try Self.writeToml( + """ + [kernel] + url = "https://example.com/custom-kernel.tar" + """, to: userFile) + try Self.writeToml( + """ + [kernel] + digest = "\(KernelConfig.defaultDigest)" + """, to: systemFile) + + await #expect(throws: (any Error).self) { + let _: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [userFile, systemFile]) + } } + } - let programmaticConfig = KernelConfig(url: URL(string: "https://example.com/custom-kernel.tar")!) - #expect(programmaticConfig.digest == nil) + @Test func customKernelURLWithDigestCanBeConstructed() { + let digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + let config = KernelConfig(url: URL(string: "https://example.com/custom-kernel.tar")!, digest: digest) + #expect(config.url.absoluteString == "https://example.com/custom-kernel.tar") + #expect(config.digest == digest) } @Test func unknownKeysIgnored() async throws { diff --git a/docs/command-reference.md b/docs/command-reference.md index 5cad7c803..c7d854806 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1553,7 +1553,7 @@ container system kernel set [--arch ] [--binary ] [--force] [--rec * `--force`: Overwrites an existing kernel with the same name * `--recommended`: Download and install the recommended kernel as the default (takes precedence over all other flags) * `--tar `: Filesystem path or remote URL to a tar archive containing a kernel file -* `--digest `: Expected digest for the tar archive, for example `sha256:` +* `--digest `: Expected digest for the tar archive, for example `sha256:`. Required when `--tar` is a remote URL. ### `container system property list (ls)` diff --git a/docs/container-system-config.md b/docs/container-system-config.md index 8bbf9cbda..72b2cfbe9 100644 --- a/docs/container-system-config.md +++ b/docs/container-system-config.md @@ -58,7 +58,7 @@ Guest kernel used when launching container VMs. Defaults change per release as k |--------------|-----------|--------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| | `binaryPath` | `String` | `"opt/kata/share/kata-containers/vmlinux-6.18.15-186"` | Path **inside** the downloaded kernel archive that points to the kernel binary. | | `url` | `URL` | `"https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst"` | Archive to download when no kernel is installed. Encoded and decoded as a plain string in TOML. | -| `digest` | `String?` | `"sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91"` | Expected digest for the archive, for example `sha256:`. When unset for a custom URL, remote kernel downloads are not verified. | +| `digest` | `String` | `"sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91"` | Expected digest for the archive, for example `sha256:`. Required when configuring a custom `url`. | ## `[network]` diff --git a/docs/how-to.md b/docs/how-to.md index 07697ecd2..62a883963 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -659,8 +659,9 @@ memory = "1gb" domain = "test" [kernel] -binaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.5-177" -url = "https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst" +binaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186" +url = "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst" +digest = "sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" [network] From 04157952976080c5318e4e4f4262413bed676152 Mon Sep 17 00:00:00 2001 From: haoruilee Date: Sun, 5 Jul 2026 19:51:24 +0800 Subject: [PATCH 4/6] Address kernel digest review feedback --- .../ConfigurationLoader.swift | 2 ++ .../ContainerSystemConfig.swift | 8 +------- .../KernelServiceTests.swift | 15 ++++++++++++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/ContainerPersistence/ConfigurationLoader.swift b/Sources/ContainerPersistence/ConfigurationLoader.swift index c0ca03d0b..4708b6677 100644 --- a/Sources/ContainerPersistence/ConfigurationLoader.swift +++ b/Sources/ContainerPersistence/ConfigurationLoader.swift @@ -197,6 +197,8 @@ public enum ConfigurationLoader { } private static func validateKernelArchiveDigest(in provider: FileProvider, path: FilePath) throws { + // Validate each layer before merging so a custom archive URL is paired with + // the digest from the same config file, not a default digest from a lower layer. let urlResult = try provider.value(forKey: AbsoluteConfigKey(ConfigKey("kernel.url")), type: .string) guard let urlValue = urlResult.value else { return diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index d8c9e52ae..611f11a01 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -180,13 +180,7 @@ final public class KernelConfig: Codable, Sendable { public let url: URL public let digest: String - public init(binaryPath: String = defaultBinaryPath) { - self.binaryPath = binaryPath - self.url = Self.defaultURL - self.digest = Self.defaultDigest - } - - public init(binaryPath: String = defaultBinaryPath, url: URL, digest: String) { + public init(binaryPath: String = defaultBinaryPath, url: URL = defaultURL, digest: String = defaultDigest) { self.binaryPath = binaryPath self.url = url self.digest = digest diff --git a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift index 8ab5b11d4..872dbb0a7 100644 --- a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift +++ b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift @@ -26,16 +26,29 @@ import Testing struct KernelServiceTests { @Test func verifyDigest() throws { try withTempFile(contents: "kernel archive") { file in - try KernelService.verifyDigest(of: file, expected: "sha256:\(KernelService.sha256Hex(of: file))") + let sha256 = try KernelService.sha256Hex(of: file) + let sha1 = "53f38d6b06c833a50448ab246037de7994443b70" + + try KernelService.verifyDigest(of: file, expected: "sha256:\(sha256)") #expect(throws: ContainerizationError.self) { try KernelService.verifyDigest(of: file, expected: "sha256-not-a-digest") } + #expect(throws: ContainerizationError.self) { + try KernelService.verifyDigest(of: file, expected: "sha1:\(sha1)") + } #expect(throws: ContainerizationError.self) { try KernelService.verifyDigest(of: file, expected: "sha256:not-a-digest") } #expect(throws: ContainerizationError.self) { try KernelService.verifyDigest(of: file, expected: String(repeating: "0", count: 64)) } + #expect(throws: ContainerizationError.self) { + let truncatedSHA256 = String(sha256.dropLast(2)) + try KernelService.verifyDigest(of: file, expected: "sha256:\(truncatedSHA256)") + } + #expect(throws: ContainerizationError.self) { + try KernelService.verifyDigest(of: file, expected: "sha256:\(sha1)") + } #expect(throws: ContainerizationError.self) { try KernelService.verifyDigest(of: file, expected: "sha256:\(String(repeating: "0", count: 64))") } From 8cc0875668d83e2b979b8d25604679e3af3b3536 Mon Sep 17 00:00:00 2001 From: haoruilee Date: Wed, 8 Jul 2026 10:30:05 +0800 Subject: [PATCH 5/6] Address kernel digest review comments --- .../ConfigurationLoader.swift | 41 +--------- .../Server/Kernel/KernelService.swift | 5 -- .../KernelServiceTests.swift | 79 ++++++++++--------- .../ConfigurationLoaderTests.swift | 10 +-- 4 files changed, 49 insertions(+), 86 deletions(-) diff --git a/Sources/ContainerPersistence/ConfigurationLoader.swift b/Sources/ContainerPersistence/ConfigurationLoader.swift index 4708b6677..760fd46f2 100644 --- a/Sources/ContainerPersistence/ConfigurationLoader.swift +++ b/Sources/ContainerPersistence/ConfigurationLoader.swift @@ -104,7 +104,6 @@ public enum ConfigurationLoader { try await loadAndDecode( ContainerSystemConfig.self, configurationFiles: configurationFiles, - validateKernelConfigLayers: true, decodeErrorContext: "failed to decode configuration" ) } @@ -149,7 +148,6 @@ public enum ConfigurationLoader { _ type: T.Type, configurationFiles: [FilePath], scope: ConfigKey? = nil, - validateKernelConfigLayers: Bool = false, decodeErrorContext: String ) async throws -> T { let paths = configurationFiles.isEmpty ? defaultConfigFiles() : configurationFiles @@ -160,28 +158,14 @@ public enum ConfigurationLoader { var providers: [FileProvider] = [] for path in paths { - let provider: FileProvider do { - provider = try await FileProvider(filePath: path, allowMissing: true) + try providers.append(await FileProvider(filePath: path, allowMissing: true)) } catch { throw ContainerizationError( .invalidArgument, message: "failed to load configuration from '\(path)': \(error)" ) } - if validateKernelConfigLayers { - do { - try validateKernelArchiveDigest(in: provider, path: path) - } catch let error as ContainerizationError { - throw error - } catch { - throw ContainerizationError( - .invalidArgument, - message: "failed to validate kernel configuration from '\(path)': \(error)" - ) - } - } - providers.append(provider) } let reader = ConfigReader(providers: providers) @@ -196,29 +180,6 @@ public enum ConfigurationLoader { } } - private static func validateKernelArchiveDigest(in provider: FileProvider, path: FilePath) throws { - // Validate each layer before merging so a custom archive URL is paired with - // the digest from the same config file, not a default digest from a lower layer. - let urlResult = try provider.value(forKey: AbsoluteConfigKey(ConfigKey("kernel.url")), type: .string) - guard let urlValue = urlResult.value else { - return - } - guard case .string(let urlString) = urlValue.content else { - return - } - guard urlString != KernelConfig.defaultURL.absoluteString else { - return - } - - let digestResult = try provider.value(forKey: AbsoluteConfigKey(ConfigKey("kernel.digest")), type: .string) - guard digestResult.value != nil else { - throw ContainerizationError( - .invalidArgument, - message: "kernel.digest is required in '\(path)' when kernel.url configures a custom archive" - ) - } - } - /// Copies the user's runtime configuration into the app-root as a read-only snapshot. /// /// If `source` does not exist, this is a no-op. Otherwise, any existing destination diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift index 05359355e..d7a8de90b 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -187,11 +187,6 @@ public actor KernelService { } } - static func verifyDigest(of file: URL, expected: String) throws { - let expectedDigest = try parseExpectedDigest(expected) - try verifyDigest(of: file, expected: expectedDigest) - } - private static func verifyDigest(of file: URL, expected: ExpectedDigest) throws { let actualDigest = try sha256Hex(of: file) try verifyDigest(actualSHA256Hex: actualDigest, expected: expected) diff --git a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift index 872dbb0a7..74e4b62bc 100644 --- a/Tests/ContainerAPIServiceTests/KernelServiceTests.swift +++ b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift @@ -17,6 +17,7 @@ import Containerization import ContainerizationArchive import ContainerizationError +import CryptoKit import Foundation import Logging import Testing @@ -24,37 +25,6 @@ import Testing @testable import ContainerAPIService struct KernelServiceTests { - @Test func verifyDigest() throws { - try withTempFile(contents: "kernel archive") { file in - let sha256 = try KernelService.sha256Hex(of: file) - let sha1 = "53f38d6b06c833a50448ab246037de7994443b70" - - try KernelService.verifyDigest(of: file, expected: "sha256:\(sha256)") - #expect(throws: ContainerizationError.self) { - try KernelService.verifyDigest(of: file, expected: "sha256-not-a-digest") - } - #expect(throws: ContainerizationError.self) { - try KernelService.verifyDigest(of: file, expected: "sha1:\(sha1)") - } - #expect(throws: ContainerizationError.self) { - try KernelService.verifyDigest(of: file, expected: "sha256:not-a-digest") - } - #expect(throws: ContainerizationError.self) { - try KernelService.verifyDigest(of: file, expected: String(repeating: "0", count: 64)) - } - #expect(throws: ContainerizationError.self) { - let truncatedSHA256 = String(sha256.dropLast(2)) - try KernelService.verifyDigest(of: file, expected: "sha256:\(truncatedSHA256)") - } - #expect(throws: ContainerizationError.self) { - try KernelService.verifyDigest(of: file, expected: "sha256:\(sha1)") - } - #expect(throws: ContainerizationError.self) { - try KernelService.verifyDigest(of: file, expected: "sha256:\(String(repeating: "0", count: 64))") - } - } - } - @Test func installKernelFromLocalTarVerifiesDigest() async throws { try await withTempDir { tempDir in let kernelPath = "boot/vmlinux" @@ -109,6 +79,45 @@ struct KernelServiceTests { } } + @Test func installKernelFromLocalTarRejectsInvalidDigestValues() async throws { + try await withTempDir { tempDir in + let kernelPath = "boot/vmlinux" + let kernelData = Data("kernel binary".utf8) + let tarFile = try Self.writeTar( + at: tempDir.appendingPathComponent("kernel.tar"), + path: kernelPath, + data: kernelData) + let service = try KernelService( + log: Logger(label: "com.apple.container.test.kernel-service"), + appRoot: tempDir.appendingPathComponent("app")) + let sha256 = try KernelService.sha256Hex(of: tarFile) + let sha1 = try Self.sha1Hex(of: tarFile) + let invalidDigests = [ + "sha256-not-a-digest", + "sha1:\(sha1)", + "sha256:not-a-digest", + String(repeating: "0", count: 64), + "sha256:\(String(sha256.dropLast(2)))", + "sha256:\(sha1)", + ] + + for digest in invalidDigests { + await #expect(throws: ContainerizationError.self) { + try await service.installKernelFrom( + tar: URL(fileURLWithPath: tarFile.path), + kernelFilePath: kernelPath, + platform: .linuxArm, + progressUpdate: nil, + expectedDigest: digest, + force: false) + } + } + await #expect(throws: ContainerizationError.self) { + _ = try await service.getDefaultKernel(platform: .linuxArm) + } + } + } + @Test func installKernelFromRemoteTarRequiresDigest() async throws { try await withTempDir { tempDir in let service = try KernelService( @@ -139,11 +148,9 @@ struct KernelServiceTests { return tarFile } - private func withTempFile(contents: String, body: (URL) throws -> Void) throws { - let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try Data(contents.utf8).write(to: file) - defer { try? FileManager.default.removeItem(at: file) } - try body(file) + private static func sha1Hex(of file: URL) throws -> String { + let data = try Data(contentsOf: file) + return Insecure.SHA1.hash(data: data).map { String(format: "%02x", $0) }.joined() } private func withTempDir(body: (URL) async throws -> Void) async throws { diff --git a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift index 8a453d33e..ea5b6ae13 100644 --- a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift +++ b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift @@ -191,7 +191,7 @@ struct ConfigurationLoaderTests { } } - @Test func layeredCustomKernelURLRequiresDigestInSameFile() async throws { + @Test func layeredCustomKernelURLCanUseDigestFromLowerLayer() async throws { try await TemporaryStorage.withTempDir { tempDir in let userFile = tempDir.appending("user.toml") let systemFile = tempDir.appending("system.toml") @@ -207,10 +207,10 @@ struct ConfigurationLoaderTests { digest = "\(KernelConfig.defaultDigest)" """, to: systemFile) - await #expect(throws: (any Error).self) { - let _: ContainerSystemConfig = try await ConfigurationLoader.load( - configurationFiles: [userFile, systemFile]) - } + let config: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [userFile, systemFile]) + #expect(config.kernel.url.absoluteString == "https://example.com/custom-kernel.tar") + #expect(config.kernel.digest == KernelConfig.defaultDigest) } } From 303e8cbe2bbf18c604a58a35625a3b8d561dbb1e Mon Sep 17 00:00:00 2001 From: haoruilee Date: Sun, 12 Jul 2026 03:00:12 +0800 Subject: [PATCH 6/6] Simplify kernel archive verification --- .../System/Kernel/KernelSet.swift | 7 +- .../Client/FileDownloader.swift | 234 +----------------- .../Server/Kernel/KernelService.swift | 12 +- .../System/TestCLIKernelSetSerial.swift | 24 ++ 4 files changed, 39 insertions(+), 238 deletions(-) diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index 5cde09362..85643e50f 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -94,9 +94,12 @@ extension Application { throw ArgumentParser.ValidationError("missing argument '--tar") } let platform = try getSystemPlatform() + let remoteURL = URL(string: tarPath) + let remoteScheme = remoteURL?.scheme?.lowercased() + let isHTTPURL = remoteScheme == "http" || remoteScheme == "https" let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path let fm = FileManager.default - if fm.fileExists(atPath: localTarPath) { + if !isHTTPURL && fm.fileExists(atPath: localTarPath) { try await ClientKernel.installKernelFromTar( tarFile: localTarPath, kernelFilePath: binaryPath, @@ -105,7 +108,7 @@ extension Application { force: force) return } - guard let remoteURL = URL(string: tarPath) else { + guard let remoteURL else { throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") } guard let digest else { diff --git a/Sources/Services/ContainerAPIService/Client/FileDownloader.swift b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift index 2e54d3a81..83aabfc51 100644 --- a/Sources/Services/ContainerAPIService/Client/FileDownloader.swift +++ b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift @@ -17,11 +17,7 @@ import AsyncHTTPClient import ContainerizationError import ContainerizationExtras -import CryptoKit import Foundation -import NIOCore -import NIOHTTP1 -import NIOPosix import TerminalProgress public struct FileDownloader { @@ -32,11 +28,13 @@ public struct FileDownloader { path: destination.path(), reportHead: { let expectedSizeString = $0.headers["Content-Length"].first ?? "" - if let expectedSize = Int64(expectedSizeString), let progressUpdate { - Task { - await progressUpdate([ - .addTotalSize(expectedSize) - ]) + if let expectedSize = Int64(expectedSizeString) { + if let progressUpdate { + Task { + await progressUpdate([ + .addTotalSize(expectedSize) + ]) + } } } }, @@ -61,59 +59,6 @@ public struct FileDownloader { try await client.shutdown() } - public static func downloadFile( - url: URL, - to destination: URL, - progressUpdate: ProgressUpdateHandler? = nil, - computingSHA256: Bool - ) async throws -> String? { - guard computingSHA256 else { - try await downloadFile(url: url, to: destination, progressUpdate: progressUpdate) - return nil - } - - let request = try HTTPClient.Request(url: url) - let fileIOThreadPool = NIOThreadPool(numberOfThreads: 1) - fileIOThreadPool.start() - - let delegate = try HashingFileDownloadDelegate( - path: destination.path(), - pool: fileIOThreadPool, - computingSHA256: computingSHA256, - reportHead: { - let expectedSizeString = $0.headers["Content-Length"].first ?? "" - if let expectedSize = Int64(expectedSizeString), let progressUpdate { - Task { - await progressUpdate([ - .addTotalSize(expectedSize) - ]) - } - } - }, - reportProgress: { - let receivedBytes = Int64($0.receivedBytes) - if let progressUpdate { - Task { - await progressUpdate([ - .setSize(receivedBytes) - ]) - } - } - }) - - let client = FileDownloader.createClient(url: url) - do { - let response = try await client.execute(request: request, delegate: delegate).get() - try await client.shutdown() - await FileDownloader.shutdown(fileIOThreadPool) - return response.sha256Digest - } catch { - try? await client.shutdown() - await FileDownloader.shutdown(fileIOThreadPool) - throw error - } - } - private static func createClient(url: URL) -> HTTPClient { var httpConfiguration = HTTPClient.Configuration() // for large file downloads we keep a generous connect timeout, and @@ -131,169 +76,4 @@ public struct FileDownloader { return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) } - - private static func shutdown(_ threadPool: NIOThreadPool) async { - await withCheckedContinuation { continuation in - threadPool.shutdownGracefully { _ in - continuation.resume() - } - } - } -} - -// Mirrors AsyncHTTPClient's file download delegate while updating an optional -// SHA-256 hasher from the same response chunks that are written to disk. -private final class HashingFileDownloadDelegate: @unchecked Sendable, HTTPClientResponseDelegate { - struct Progress: Sendable { - var totalBytes: Int? - var receivedBytes: Int - } - - struct Response: Sendable { - var progress: Progress - var sha256Digest: String? - } - - private struct State { - var progress = Progress(totalBytes: nil, receivedBytes: 0) - var fileHandleFuture: EventLoopFuture? - var writeFuture: EventLoopFuture? - var sha256: SHA256? - } - - private let filePath: String - private let fileIOThreadPool: NIOThreadPool - private let reportHead: (@Sendable (HTTPResponseHead) -> Void)? - private let reportProgress: (@Sendable (Progress) -> Void)? - private let lock = NSLock() - private var state: State - - init( - path: String, - pool: NIOThreadPool, - computingSHA256: Bool, - reportHead: (@Sendable (HTTPResponseHead) -> Void)? = nil, - reportProgress: (@Sendable (Progress) -> Void)? = nil - ) throws { - self.filePath = path - self.fileIOThreadPool = pool - self.reportHead = reportHead - self.reportProgress = reportProgress - self.state = State(sha256: computingSHA256 ? SHA256() : nil) - } - - func didReceiveHead(task: HTTPClient.Task, _ head: HTTPResponseHead) -> EventLoopFuture { - withState { - if let totalBytesString = head.headers.first(name: "Content-Length"), - let totalBytes = Int(totalBytesString) - { - $0.progress.totalBytes = totalBytes - } - } - reportHead?(head) - return task.eventLoop.makeSucceededFuture(()) - } - - func didReceiveBodyPart(task: HTTPClient.Task, _ buffer: ByteBuffer) -> EventLoopFuture { - buffer.withUnsafeReadableBytes { readableBytes in - withState { - $0.sha256?.update(bufferPointer: readableBytes) - } - } - - let (progress, io) = withState { state in - let io = NonBlockingFileIO(threadPool: fileIOThreadPool) - state.progress.receivedBytes += buffer.readableBytes - return (state.progress, io) - } - reportProgress?(progress) - - let writeFuture = withState { state in - let writeFuture: EventLoopFuture - if let fileHandleFuture = state.fileHandleFuture { - writeFuture = fileHandleFuture.flatMap { - io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop) - } - } else { - let fileHandleFuture = io.openFile( - _deprecatedPath: filePath, - mode: .write, - flags: .allowFileCreation(), - eventLoop: task.eventLoop - ) - state.fileHandleFuture = fileHandleFuture - writeFuture = fileHandleFuture.flatMap { - io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop) - } - } - - state.writeFuture = writeFuture - return writeFuture - } - - return writeFuture - } - - func didReceiveError(task: HTTPClient.Task, _ error: Error) { - finalize() - } - - func didFinishRequest(task: HTTPClient.Task) throws -> Response { - finalize() - return withState { state in - let digest = state.sha256?.finalize().map { String(format: "%02x", $0) }.joined() - return Response(progress: state.progress, sha256Digest: digest) - } - } - - private func close(fileHandle: NIOFileHandle) { - try! fileHandle.close() - withState { - $0.fileHandleFuture = nil - } - } - - private func finalize() { - enum Finalize { - case writeFuture(EventLoopFuture) - case fileHandleFuture(EventLoopFuture) - case none - } - - let finalize = withState { state in - if let writeFuture = state.writeFuture { - return Finalize.writeFuture(writeFuture) - } else if let fileHandleFuture = state.fileHandleFuture { - return Finalize.fileHandleFuture(fileHandleFuture) - } else { - return Finalize.none - } - } - - switch finalize { - case .writeFuture(let future): - future.whenComplete { _ in - let fileHandleFuture = self.withState { state in - let future = state.fileHandleFuture - state.fileHandleFuture = nil - state.writeFuture = nil - return future - } - - fileHandleFuture?.whenSuccess { - self.close(fileHandle: $0) - } - } - case .fileHandleFuture(let future): - future.whenSuccess { self.close(fileHandle: $0) } - case .none: - () - } - } - - private func withState(_ body: (inout State) -> T) -> T { - lock.lock() - defer { lock.unlock() } - return body(&state) - } } diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift index d7a8de90b..33d8d2e3b 100644 --- a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -138,7 +138,6 @@ public actor KernelService { await progressUpdate?([ .setDescription(isLocalTar ? "Reading kernel archive" : "Downloading kernel") ]) - var downloadedSHA256Digest: String? if !isLocalTar { let taskManager = ProgressTaskCoordinator() let downloadTask = await taskManager.startTask() @@ -148,11 +147,10 @@ public actor KernelService { if let progressUpdate { downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) } - downloadedSHA256Digest = try await ContainerAPIClient.FileDownloader.downloadFile( + try await ContainerAPIClient.FileDownloader.downloadFile( url: tar, to: tarFile, - progressUpdate: downloadProgressUpdate, - computingSHA256: expectedDigest != nil) + progressUpdate: downloadProgressUpdate) await taskManager.finish() } await progressUpdate?([ @@ -163,11 +161,7 @@ public actor KernelService { await progressUpdate?([ .setDescription("Verifying kernel archive") ]) - if let downloadedSHA256Digest { - try Self.verifyDigest(actualSHA256Hex: downloadedSHA256Digest, expected: expectedDigest) - } else { - try Self.verifyDigest(of: tarFile, expected: expectedDigest) - } + try Self.verifyDigest(of: tarFile, expected: expectedDigest) await progressUpdate?([ .addTasks(1) ]) diff --git a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift index d34e0c230..5b66c5f5b 100644 --- a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift +++ b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift @@ -50,6 +50,30 @@ struct TestCLIKernelSetSerial { // MARK: - Tests + @Test func remoteTarCannotBeShadowedByLocalPath() async throws { + try await ContainerFixture.with { f in + let shadow = URL(filePath: f.testDir.string) + .appending(path: "https:") + .appending(path: "example.com") + .appending(path: "kernel.tar") + try FileManager.default.createDirectory( + at: shadow.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data().write(to: shadow) + + let result = try f.run( + [ + "system", "kernel", "set", + "--tar", "https://example.com/kernel.tar", + "--binary", "vmlinux", + ], + currentDirectory: f.testDir) + + #expect(result.status != 0) + #expect(result.error.contains("'--digest' is required when '--tar' is a remote URL")) + } + } + @Test func fromLocalTar() async throws { let symlinkBinaryPath = URL(filePath: defaultBinaryPath) .deletingLastPathComponent()