diff --git a/Package.swift b/Package.swift index 8a6b4fe6d..cba3814ab 100644 --- a/Package.swift +++ b/Package.swift @@ -227,6 +227,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..85643e50f 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 digest for the tar archive, for example sha256:. Required when --tar is a remote URL.") + var digest: 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, + expectedDigest: containerSystemConfig.kernel.digest, + force: force) return } guard tarPath != nil else { @@ -68,6 +75,9 @@ extension Application { } private func setKernelFromBinary() async throws { + guard digest == nil else { + throw ArgumentParser.ValidationError("'--digest' can only be used with '--tar'") + } guard let binaryPath else { throw ArgumentParser.ValidationError("missing argument '--binary'") } @@ -84,16 +94,32 @@ 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) { - try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force) + if !isHTTPURL && fm.fileExists(atPath: localTarPath) { + try await ClientKernel.installKernelFromTar( + tarFile: localTarPath, + kernelFilePath: binaryPath, + platform: platform, + expectedDigest: digest, + 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?") } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, force: force) + guard let digest else { + throw ArgumentParser.ValidationError("'--digest' is required when '--tar' is a remote URL") + } + try await Self.downloadAndInstallWithProgressBar( + tarRemoteURL: remoteURL, + kernelFilePath: binaryPath, + platform: platform, + expectedDigest: digest, + force: force) } private func getSystemPlatform() throws -> SystemPlatform { @@ -107,10 +133,16 @@ 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, + expectedDigest: String, + force: Bool + ) async throws { let progressConfig = try ProgressConfig( showTasks: true, - totalTasks: 2 + totalTasks: 3 ) let progress = ProgressBar(config: progressConfig) defer { @@ -118,7 +150,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, + expectedDigest: expectedDigest, + force: force) progress.finish() } diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index ca3b1a834..b91eb7964 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, + kernelDigest: containerSystemConfig.kernel.digest) } 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, kernelDigest: 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, + expectedDigest: kernelDigest, + force: true) } private func initImageExists(containerSystemConfig: ContainerSystemConfig) async -> Bool { diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index c3b04e2ce..611f11a01 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -168,21 +168,22 @@ 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 defaultDigest = "sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" private enum CodingKeys: String, CodingKey { case binaryPath case url + case digest } public let binaryPath: String public let url: URL + public let digest: String - public init( - binaryPath: String = defaultBinaryPath, - url: URL = defaultURL - ) { + public init(binaryPath: String = defaultBinaryPath, url: URL = defaultURL, digest: String = defaultDigest) { self.binaryPath = binaryPath self.url = url + self.digest = digest } public init(from decoder: any Decoder) throws { @@ -190,13 +191,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 } + 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 @@ -209,6 +224,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.encode(digest, forKey: .digest) } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientKernel.swift b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift index 3cac4693c..44e79e062 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, + expectedDigest: 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 expectedDigest { + message.set(key: .kernelDigest, value: expectedDigest) + } 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..a4d5aebd3 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 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 d12905778..8808d3568 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 expectedDigest = message.kernelDigest() 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, + expectedDigest: expectedDigest, + 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 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 84c55a532..33d8d2e3b 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 @@ -29,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") @@ -81,7 +87,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?, + expectedDigest: String? = nil, + force: Bool + ) async throws { log.debug( "KernelService: enter", metadata: [ @@ -103,39 +116,110 @@ public actor KernelService { ) } + 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 { 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 - if !FileManager.default.fileExists(atPath: tar.absoluteString) { + 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) + try await ContainerAPIClient.FileDownloader.downloadFile( + url: tar, + to: tarFile, + progressUpdate: downloadProgressUpdate) + await taskManager.finish() + } + await progressUpdate?([ + .addTasks(1) + ]) + + if let expectedDigest { + await progressUpdate?([ + .setDescription("Verifying kernel archive") + ]) + try Self.verifyDigest(of: tarFile, expected: expectedDigest) + await progressUpdate?([ + .addTasks(1) + ]) } - await taskManager.finish() 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) + await progressUpdate?([ + .addTasks(1) + ]) - if !FileManager.default.fileExists(atPath: tar.absoluteString) { + if !isLocalTar { try FileManager.default.removeItem(at: tarFile) } } + private static func verifyDigest(of file: URL, expected: ExpectedDigest) throws { + let actualDigest = try sha256Hex(of: file) + 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 digest mismatch: expected sha256:\(expected.hex), got sha256:\(actualDigest)" + ) + } + } + + 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 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 digest + } + + 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/ContainerAPIServiceTests/KernelServiceTests.swift b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift new file mode 100644 index 000000000..74e4b62bc --- /dev/null +++ b/Tests/ContainerAPIServiceTests/KernelServiceTests.swift @@ -0,0 +1,162 @@ +//===----------------------------------------------------------------------===// +// 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 CryptoKit +import Foundation +import Logging +import Testing + +@testable import ContainerAPIService + +struct KernelServiceTests { + @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, + expectedDigest: "sha256:\(digest)", + force: false) + + let kernel = try await service.getDefaultKernel(platform: .linuxArm) + #expect(try Data(contentsOf: kernel.path) == kernelData) + } + } + + @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) + } + } + } + + @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( + 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() + 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 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 { + 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..ea5b6ae13 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.digest == KernelConfig.defaultDigest) #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" + digest = "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.digest == "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,51 @@ struct ConfigurationLoaderTests { } } + @Test func customKernelURLWithoutDigestThrows() 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) + + await #expect(throws: (any Error).self) { + let _: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + } + } + } + + @Test func layeredCustomKernelURLCanUseDigestFromLowerLayer() 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) + + 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) + } + } + + @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 { try await TemporaryStorage.withTempDir { tempDir in let toml = """ diff --git a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift index 6b18ccaa0..5b66c5f5b 100644 --- a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift +++ b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift @@ -26,6 +26,7 @@ import Testing struct TestCLIKernelSetSerial { private let remoteTar = ContainerSystemConfig().kernel.url private let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath + private let defaultDigest = KernelConfig.defaultDigest /// Kernel release string parsed from the binary filename. /// @@ -49,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() @@ -60,7 +85,13 @@ struct TestCLIKernelSetSerial { let tempDir = URL(filePath: f.testDir.string) let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) - try f.run(["system", "kernel", "set", "--force", "--tar", localTarPath.path, "--binary", symlinkBinaryPath]).check() + try f.run([ + "system", "kernel", "set", + "--force", + "--tar", localTarPath.path, + "--binary", symlinkBinaryPath, + "--digest", defaultDigest, + ]).check() try await validateGuestKernel(f) } } @@ -73,7 +104,13 @@ struct TestCLIKernelSetSerial { try await ContainerFixture.with { f in f.addCleanup { resetKernelToRecommended(f) } - try f.run(["system", "kernel", "set", "--force", "--tar", remoteTar.absoluteString, "--binary", symlinkBinaryPath]).check() + try f.run([ + "system", "kernel", "set", + "--force", + "--tar", remoteTar.absoluteString, + "--binary", symlinkBinaryPath, + "--digest", defaultDigest, + ]).check() try await validateGuestKernel(f) } } diff --git a/docs/command-reference.md b/docs/command-reference.md index 4066e3a38..c7d854806 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1543,7 +1543,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 ] [--digest ] [--debug] ``` **Options** @@ -1553,6 +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:`. 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 547ae5ff7..72b2cfbe9 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. | +| `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] diff --git a/docs/tutorials/container-system-config-tutorial.md b/docs/tutorials/container-system-config-tutorial.md index 05a2bfa6e..bcb168ea8 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" +digest = "sha256:f63d54507d1f18635d94475077e4c2330de4d8e05cedf25f7c38f063b0e66a91" [network]