mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Verify kernel archive integrity (#1703)
Closes https://github.com/apple/container/issues/1687 The default kernel archive is downloaded from a remote release URL during first-run setup and via `container system kernel set --recommended`. Previously, the archive contents were not verified after download, so integrity depended on HTTPS and the release artifact remaining unchanged. This change adds digest verification for kernel archives. The recommended/default kernel now has pinned digest metadata using an algorithm-prefixed value such as `sha256:<hex>`. `container system kernel set --tar` accepts `--digest`; remote tar URLs require it, and local tar archives can also be verified before unpacking and installation. The system config also supports `kernel.digest`, and a custom `kernel.url` must provide a digest for that archive.
This commit is contained in:
@@ -211,6 +211,7 @@ let package = Package(
|
||||
name: "ContainerAPIServiceTests",
|
||||
dependencies: [
|
||||
.product(name: "Containerization", package: "containerization"),
|
||||
"ContainerAPIService",
|
||||
"ContainerResource",
|
||||
"ContainerRuntimeLinuxClient",
|
||||
"ContainerRuntimeClient",
|
||||
|
||||
@@ -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:<hex>. 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -112,6 +112,7 @@ public enum XPCKeys: String {
|
||||
case kernelFilePath
|
||||
case systemPlatform
|
||||
case kernelForce
|
||||
case kernelDigest
|
||||
|
||||
/// Init image reference
|
||||
case initImage
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '<algorithm>:<hex>'")
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <arch>] [--binary <binary>] [--force] [--recommended] [--tar <tar>] [--debug]
|
||||
container system kernel set [--arch <arch>] [--binary <binary>] [--force] [--recommended] [--tar <tar>] [--digest <digest>] [--debug]
|
||||
```
|
||||
|
||||
**Options**
|
||||
@@ -1553,6 +1553,7 @@ container system kernel set [--arch <arch>] [--binary <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 <tar>`: Filesystem path or remote URL to a tar archive containing a kernel file
|
||||
* `--digest <digest>`: Expected digest for the tar archive, for example `sha256:<hex>`. Required when `--tar` is a remote URL.
|
||||
|
||||
### `container system property list (ls)`
|
||||
|
||||
|
||||
@@ -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:<hex>`. Required when configuring a custom `url`. |
|
||||
|
||||
## `[network]`
|
||||
|
||||
|
||||
+3
-2
@@ -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]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user