mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Update for containerization 0.21.0. (#1056)
- Update image load and build to handle rejected paths during tar extraction. For the image load command there is now a `--force` function that fails extractions with rejected paths when false, and just warns about the rejected paths when true. - Update `container stats` for statistics API properties now all being optional. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [x] Documentation update ## Motivation and Context See above ## Testing - [x] Tested locally - [x] Added/updated tests - [x] Added/updated docs
This commit is contained in:
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "b11a80bed0b568e35f4b538956344d483125e344c4931c362a2a31a2f7a8565b",
|
||||
"originHash" : "404d8d0e91cd9206e8cbf751ae0a4a9d75d4e631f05045e0dbee345d0144772b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/containerization.git",
|
||||
"state" : {
|
||||
"revision" : "452f354bac52ecbfe4a40b729880435a070c5a29",
|
||||
"version" : "0.20.1"
|
||||
"revision" : "f570b8734ebd11727655bc68d4597bda1656365e",
|
||||
"version" : "0.21.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-1
@@ -23,7 +23,7 @@ import PackageDescription
|
||||
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
|
||||
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
|
||||
let builderShimVersion = "0.7.0"
|
||||
let scVersion = "0.20.1"
|
||||
let scVersion = "0.21.0"
|
||||
|
||||
let package = Package(
|
||||
name: "container",
|
||||
@@ -72,6 +72,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||
.product(name: "Containerization", package: "containerization"),
|
||||
.product(name: "ContainerizationArchive", package: "containerization"),
|
||||
.product(name: "ContainerizationExtras", package: "containerization"),
|
||||
.product(name: "ContainerizationOS", package: "containerization"),
|
||||
"ContainerBuild",
|
||||
|
||||
@@ -354,9 +354,12 @@ extension Application {
|
||||
guard let dest = exp.destination else {
|
||||
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
|
||||
}
|
||||
let loaded = try await ClientImage.load(from: dest.absolutePath())
|
||||
|
||||
for image in loaded {
|
||||
let result = try await ClientImage.load(from: dest.absolutePath(), force: false)
|
||||
guard result.rejectedMembers.isEmpty else {
|
||||
log.error("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
|
||||
throw ContainerizationError(.internalError, message: "failed to load archive")
|
||||
}
|
||||
for image in result.images {
|
||||
try Task.checkCancellation()
|
||||
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))
|
||||
|
||||
|
||||
@@ -198,15 +198,15 @@ extension Application {
|
||||
/// - timeDeltaUsec: Time delta between samples in microseconds
|
||||
/// - Returns: CPU percentage where 100% = one fully utilized core
|
||||
static func calculateCPUPercent(
|
||||
cpuUsageUsec1: UInt64,
|
||||
cpuUsageUsec2: UInt64,
|
||||
timeDeltaUsec: UInt64
|
||||
cpuUsage1: Duration,
|
||||
cpuUsage2: Duration,
|
||||
timeInterval: Duration
|
||||
) -> Double {
|
||||
let cpuDelta =
|
||||
cpuUsageUsec2 > cpuUsageUsec1
|
||||
? cpuUsageUsec2 - cpuUsageUsec1
|
||||
: 0
|
||||
return (Double(cpuDelta) / Double(timeDeltaUsec)) * 100.0
|
||||
cpuUsage2 > cpuUsage1
|
||||
? cpuUsage2 - cpuUsage1
|
||||
: .seconds(0)
|
||||
return (cpuDelta / timeInterval) * 100.0
|
||||
}
|
||||
|
||||
static func formatBytes(_ bytes: UInt64) -> String {
|
||||
@@ -226,27 +226,43 @@ extension Application {
|
||||
}
|
||||
|
||||
private func printStatsTable(_ statsData: [StatsSnapshot]) {
|
||||
let header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]]
|
||||
var rows = header
|
||||
let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]
|
||||
let notAvailable = "--"
|
||||
var rows = [headerRow]
|
||||
|
||||
for snapshot in statsData {
|
||||
var row = [snapshot.container.id]
|
||||
let stats1 = snapshot.stats1
|
||||
let stats2 = snapshot.stats2
|
||||
|
||||
let cpuPercent = Self.calculateCPUPercent(
|
||||
cpuUsageUsec1: stats1.cpuUsageUsec,
|
||||
cpuUsageUsec2: stats2.cpuUsageUsec,
|
||||
timeDeltaUsec: 2_000_000 // 2 seconds in microseconds
|
||||
)
|
||||
let cpuStr = String(format: "%.2f%%", cpuPercent)
|
||||
if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec {
|
||||
let cpuPercent = Self.calculateCPUPercent(
|
||||
cpuUsage1: .microseconds(cpuUsageUsec1),
|
||||
cpuUsage2: .microseconds(cpuUsageUsec2),
|
||||
timeInterval: .seconds(2)
|
||||
)
|
||||
let cpuStr = String(format: "%.2f%%", cpuPercent)
|
||||
row.append(cpuStr)
|
||||
} else {
|
||||
row.append(notAvailable)
|
||||
}
|
||||
|
||||
let memUsageStr = "\(Self.formatBytes(stats2.memoryUsageBytes)) / \(Self.formatBytes(stats2.memoryLimitBytes))"
|
||||
let netStr = "\(Self.formatBytes(stats2.networkRxBytes)) / \(Self.formatBytes(stats2.networkTxBytes))"
|
||||
let blockStr = "\(Self.formatBytes(stats2.blockReadBytes)) / \(Self.formatBytes(stats2.blockWriteBytes))"
|
||||
let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
row.append("\(memUsageStr) / \(memLimitStr)")
|
||||
|
||||
let pidsStr = "\(stats2.numProcesses)"
|
||||
let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
row.append("\(netRxStr) / \(netTxStr)")
|
||||
|
||||
rows.append([snapshot.container.id, cpuStr, memUsageStr, netStr, blockStr, pidsStr])
|
||||
let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
row.append("\(blkReadStr) / \(blkWriteStr)")
|
||||
|
||||
let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable
|
||||
row.append(pidsStr)
|
||||
|
||||
rows.append(row)
|
||||
}
|
||||
|
||||
// Always print header, even if no containers
|
||||
|
||||
@@ -36,6 +36,9 @@ extension Application {
|
||||
})
|
||||
var input: String?
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files")
|
||||
public var force = false
|
||||
|
||||
@OptionGroup
|
||||
var global: Flags.Global
|
||||
|
||||
@@ -81,19 +84,24 @@ extension Application {
|
||||
progress.start()
|
||||
|
||||
progress.set(description: "Loading tar archive")
|
||||
let loaded = try await ClientImage.load(from: input ?? tempFile.path())
|
||||
let result = try await ClientImage.load(
|
||||
from: input ?? tempFile.path(),
|
||||
force: force)
|
||||
if !result.rejectedMembers.isEmpty {
|
||||
log.warning("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
|
||||
}
|
||||
|
||||
let taskManager = ProgressTaskCoordinator()
|
||||
let unpackTask = await taskManager.startTask()
|
||||
progress.set(description: "Unpacking image")
|
||||
progress.set(itemsName: "entries")
|
||||
for image in loaded {
|
||||
for image in result.images {
|
||||
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
|
||||
}
|
||||
await taskManager.finish()
|
||||
progress.finish()
|
||||
print("Loaded images:")
|
||||
for image in loaded {
|
||||
for image in result.images {
|
||||
print(image.reference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,32 +21,32 @@ public struct ContainerStats: Sendable, Codable {
|
||||
/// Container ID
|
||||
public var id: String
|
||||
/// Physical memory usage in bytes
|
||||
public var memoryUsageBytes: UInt64
|
||||
public var memoryUsageBytes: UInt64?
|
||||
/// Memory limit in bytes
|
||||
public var memoryLimitBytes: UInt64
|
||||
public var memoryLimitBytes: UInt64?
|
||||
/// CPU usage in microseconds
|
||||
public var cpuUsageUsec: UInt64
|
||||
public var cpuUsageUsec: UInt64?
|
||||
/// Network received bytes (sum of all interfaces)
|
||||
public var networkRxBytes: UInt64
|
||||
public var networkRxBytes: UInt64?
|
||||
/// Network transmitted bytes (sum of all interfaces)
|
||||
public var networkTxBytes: UInt64
|
||||
public var networkTxBytes: UInt64?
|
||||
/// Block I/O read bytes (sum of all devices)
|
||||
public var blockReadBytes: UInt64
|
||||
public var blockReadBytes: UInt64?
|
||||
/// Block I/O write bytes (sum of all devices)
|
||||
public var blockWriteBytes: UInt64
|
||||
public var blockWriteBytes: UInt64?
|
||||
/// Number of processes in the container
|
||||
public var numProcesses: UInt64
|
||||
public var numProcesses: UInt64?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
memoryUsageBytes: UInt64,
|
||||
memoryLimitBytes: UInt64,
|
||||
cpuUsageUsec: UInt64,
|
||||
networkRxBytes: UInt64,
|
||||
networkTxBytes: UInt64,
|
||||
blockReadBytes: UInt64,
|
||||
blockWriteBytes: UInt64,
|
||||
numProcesses: UInt64
|
||||
memoryUsageBytes: UInt64?,
|
||||
memoryLimitBytes: UInt64?,
|
||||
cpuUsageUsec: UInt64?,
|
||||
networkRxBytes: UInt64?,
|
||||
networkTxBytes: UInt64?,
|
||||
blockReadBytes: UInt64?,
|
||||
blockWriteBytes: UInt64?,
|
||||
numProcesses: UInt64?
|
||||
) {
|
||||
self.id = id
|
||||
self.memoryUsageBytes = memoryUsageBytes
|
||||
|
||||
@@ -280,16 +280,18 @@ extension ClientImage {
|
||||
let _ = try await client.send(request)
|
||||
}
|
||||
|
||||
public static func load(from tarFile: String) async throws -> [ClientImage] {
|
||||
public static func load(from tarFile: String, force: Bool = false) async throws -> ImageLoadResult {
|
||||
let client = newXPCClient()
|
||||
let request = newRequest(.imageLoad)
|
||||
request.set(key: .filePath, value: tarFile)
|
||||
request.set(key: .forceLoad, value: force)
|
||||
let reply = try await client.send(request)
|
||||
|
||||
let loaded = try reply.imageDescriptions()
|
||||
return loaded.map { desc in
|
||||
let (descriptions, rejectedMembers) = try reply.loadResults()
|
||||
let images = descriptions.map { desc in
|
||||
ClientImage(description: desc)
|
||||
}
|
||||
return ImageLoadResult(images: images, rejectedMembers: rejectedMembers)
|
||||
}
|
||||
|
||||
public static func cleanupOrphanedBlobs() async throws -> ([String], UInt64) {
|
||||
@@ -461,14 +463,28 @@ extension XPCMessage {
|
||||
}
|
||||
|
||||
fileprivate func imageDescriptions() throws -> [ImageDescription] {
|
||||
let responseData = self.dataNoCopy(key: .imageDescriptions)
|
||||
guard let responseData else {
|
||||
let imagesData = self.dataNoCopy(key: .imageDescriptions)
|
||||
guard let imagesData else {
|
||||
throw ContainerizationError(.empty, message: "imageDescriptions not received")
|
||||
}
|
||||
let descriptions = try JSONDecoder().decode([ImageDescription].self, from: responseData)
|
||||
let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData)
|
||||
return descriptions
|
||||
}
|
||||
|
||||
fileprivate func loadResults() throws -> ([ImageDescription], [String]) {
|
||||
let imagesData = self.dataNoCopy(key: .imageDescriptions)
|
||||
guard let imagesData else {
|
||||
throw ContainerizationError(.empty, message: "imageDescriptions not received")
|
||||
}
|
||||
let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData)
|
||||
let rejectedMembersData = self.dataNoCopy(key: .rejectedMembers)
|
||||
guard let rejectedMembersData else {
|
||||
throw ContainerizationError(.empty, message: "rejectedMembers not received")
|
||||
}
|
||||
let rejectedMembers = try JSONDecoder().decode([String].self, from: rejectedMembersData)
|
||||
return (descriptions, rejectedMembers)
|
||||
}
|
||||
|
||||
fileprivate func filesystem() throws -> Filesystem {
|
||||
let responseData = self.dataNoCopy(key: .filesystem)
|
||||
guard let responseData else {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// The result of loading an archive file into the image store.
|
||||
public struct ImageLoadResult {
|
||||
/// The successfully loaded images
|
||||
public let images: [ClientImage]
|
||||
|
||||
/// The archive member files that were not extracted due
|
||||
/// to invalid paths or attempted symlink traversal.
|
||||
public let rejectedMembers: [String]
|
||||
}
|
||||
@@ -36,6 +36,8 @@ public enum ImagesServiceXPCKeys: String {
|
||||
case insecureFlag
|
||||
case garbageCollect
|
||||
case maxConcurrentDownloads
|
||||
case forceLoad
|
||||
case rejectedMembers
|
||||
|
||||
/// ContentStore
|
||||
case digest
|
||||
|
||||
@@ -107,20 +107,25 @@ public actor ImagesService {
|
||||
try writer.finishEncoding()
|
||||
}
|
||||
|
||||
public func load(from tarFile: URL) async throws -> [ImageDescription] {
|
||||
self.log.info("ImagesService: \(#function) from: \(tarFile.absolutePath())")
|
||||
public func load(from tarFile: URL, force: Bool) async throws -> ([ImageDescription], [String]) {
|
||||
let archivePathname = tarFile.absolutePath()
|
||||
self.log.info("ImagesService: \(#function) from: \(archivePathname)")
|
||||
let reader = try ArchiveReader(file: tarFile)
|
||||
let tempDir = FileManager.default.uniqueTemporaryDirectory()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
try reader.extractContents(to: tempDir)
|
||||
let rejectedMembers = try reader.extractContents(to: tempDir)
|
||||
guard rejectedMembers.isEmpty || force else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot load tar image with rejected paths: \(rejectedMembers)")
|
||||
}
|
||||
|
||||
let loaded = try await self.imageStore.load(from: tempDir)
|
||||
var images: [ImageDescription] = []
|
||||
for image in loaded {
|
||||
images.append(image.description.fromCZ)
|
||||
}
|
||||
return images
|
||||
return (images, rejectedMembers)
|
||||
}
|
||||
|
||||
public func cleanupOrphanedBlobs() async throws -> ([String], UInt64) {
|
||||
|
||||
@@ -162,16 +162,22 @@ public struct ImagesServiceHarness: Sendable {
|
||||
@Sendable
|
||||
public func load(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let input = message.string(key: .filePath)
|
||||
let force = message.bool(key: .forceLoad)
|
||||
guard let input else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "missing input file path"
|
||||
)
|
||||
}
|
||||
let images = try await service.load(from: URL(filePath: input))
|
||||
let data = try JSONEncoder().encode(images)
|
||||
let (images, rejectedMembers) = try await service.load(
|
||||
from: URL(filePath: input),
|
||||
force: force
|
||||
)
|
||||
let reply = message.reply()
|
||||
reply.set(key: .imageDescriptions, value: data)
|
||||
let imagesData = try JSONEncoder().encode(images)
|
||||
reply.set(key: .imageDescriptions, value: imagesData)
|
||||
let rejectedData = try JSONEncoder().encode(rejectedMembers)
|
||||
reply.set(key: .rejectedMembers, value: rejectedData)
|
||||
return reply
|
||||
}
|
||||
|
||||
|
||||
@@ -277,14 +277,14 @@ public actor SandboxService {
|
||||
|
||||
let containerStats = ContainerStats(
|
||||
id: stats.id,
|
||||
memoryUsageBytes: stats.memory.usageBytes,
|
||||
memoryLimitBytes: stats.memory.limitBytes,
|
||||
cpuUsageUsec: stats.cpu.usageUsec,
|
||||
networkRxBytes: stats.networks.reduce(0) { $0 + $1.receivedBytes },
|
||||
networkTxBytes: stats.networks.reduce(0) { $0 + $1.transmittedBytes },
|
||||
blockReadBytes: stats.blockIO.devices.reduce(0) { $0 + $1.readBytes },
|
||||
blockWriteBytes: stats.blockIO.devices.reduce(0) { $0 + $1.writeBytes },
|
||||
numProcesses: stats.process.current
|
||||
memoryUsageBytes: stats.memory?.usageBytes,
|
||||
memoryLimitBytes: stats.memory?.limitBytes,
|
||||
cpuUsageUsec: stats.cpu?.usageUsec,
|
||||
networkRxBytes: stats.networks?.reduce(0) { $0 + $1.receivedBytes },
|
||||
networkTxBytes: stats.networks?.reduce(0) { $0 + $1.transmittedBytes },
|
||||
blockReadBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.readBytes },
|
||||
blockWriteBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.writeBytes },
|
||||
numProcesses: stats.process?.current
|
||||
)
|
||||
|
||||
let reply = message.reply()
|
||||
|
||||
@@ -47,8 +47,10 @@ class TestCLIStatsCommand: CLITest {
|
||||
|
||||
#expect(stats.count == 1, "expected stats for one container")
|
||||
#expect(stats[0].id == name, "container ID should match")
|
||||
#expect(stats[0].memoryUsageBytes > 0, "memory usage should be non-zero")
|
||||
#expect(stats[0].numProcesses >= 1, "should have at least one process")
|
||||
let memoryUsageBytes = try #require(stats[0].memoryUsageBytes)
|
||||
let numProcesses = try #require(stats[0].numProcesses)
|
||||
#expect(memoryUsageBytes > 0, "memory usage should be non-zero")
|
||||
#expect(numProcesses >= 1, "should have at least one process")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerAPIClient
|
||||
import ContainerizationArchive
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import Testing
|
||||
@@ -310,6 +311,139 @@ class TestCLIImagesCommand: CLITest {
|
||||
"Expected validation error message in output")
|
||||
}
|
||||
|
||||
@Test func testImageLoadRejectsInvalidMembersWithoutForce() throws {
|
||||
do {
|
||||
// 0. Generate unique malicious filename for this test run
|
||||
let maliciousFilename = "pwned-\(UUID().uuidString).txt"
|
||||
let maliciousPath = "/tmp/\(maliciousFilename)"
|
||||
|
||||
// 1. Pull image
|
||||
try doPull(imageName: alpine)
|
||||
|
||||
// 2. Tag image so we can safely remove later
|
||||
let alpineRef: Reference = try Reference.parse(alpine)
|
||||
let alpineTagged = "\(alpineRef.name):testImageLoadRejectsInvalidMembers"
|
||||
try doImageTag(image: alpine, newName: alpineTagged)
|
||||
let taggedImagePresent = try isImagePresent(targetImage: alpineTagged)
|
||||
#expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged")
|
||||
|
||||
// 3. Save the image as a tarball
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
let tempFile = tempDir.appendingPathComponent(UUID().uuidString)
|
||||
let saveArgs = [
|
||||
"image",
|
||||
"save",
|
||||
alpineTagged,
|
||||
"--output",
|
||||
tempFile.path(),
|
||||
]
|
||||
let (_, _, saveError, saveStatus) = try run(arguments: saveArgs)
|
||||
if saveStatus != 0 {
|
||||
throw CLIError.executionFailed("save command failed: \(saveError)")
|
||||
}
|
||||
|
||||
// 4. Add malicious member to the tar
|
||||
try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename)
|
||||
|
||||
// 5. Remove the image
|
||||
try doRemoveImages(images: [alpineTagged])
|
||||
let imageRemoved = try !isImagePresent(targetImage: alpineTagged)
|
||||
#expect(imageRemoved, "expected image \(alpineTagged) to be removed")
|
||||
|
||||
// 6. Try to load the modified tar without force - should fail
|
||||
let loadArgs = [
|
||||
"image",
|
||||
"load",
|
||||
"-i",
|
||||
tempFile.path(),
|
||||
]
|
||||
let (_, _, loadError, loadStatus) = try run(arguments: loadArgs)
|
||||
#expect(loadStatus != 0, "expected load to fail without force flag")
|
||||
#expect(loadError.contains("rejected paths") || loadError.contains(maliciousFilename), "expected error about invalid member path")
|
||||
|
||||
// 7. Verify that malicious file was NOT created
|
||||
let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath)
|
||||
#expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)")
|
||||
} catch {
|
||||
Issue.record("failed to test image load with invalid members: \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testImageLoadAcceptsInvalidMembersWithForce() throws {
|
||||
do {
|
||||
// 0. Generate unique malicious filename for this test run
|
||||
let maliciousFilename = "pwned-\(UUID().uuidString).txt"
|
||||
let maliciousPath = "/tmp/\(maliciousFilename)"
|
||||
|
||||
// 1. Pull image
|
||||
try doPull(imageName: alpine)
|
||||
|
||||
// 2. Tag image so we can safely remove later
|
||||
let alpineRef: Reference = try Reference.parse(alpine)
|
||||
let alpineTagged = "\(alpineRef.name):testImageLoadAcceptsInvalidMembers"
|
||||
try doImageTag(image: alpine, newName: alpineTagged)
|
||||
let taggedImagePresent = try isImagePresent(targetImage: alpineTagged)
|
||||
#expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged")
|
||||
|
||||
// 3. Save the image as a tarball
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
let tempFile = tempDir.appendingPathComponent(UUID().uuidString)
|
||||
let saveArgs = [
|
||||
"image",
|
||||
"save",
|
||||
alpineTagged,
|
||||
"--output",
|
||||
tempFile.path(),
|
||||
]
|
||||
let (_, _, saveError, saveStatus) = try run(arguments: saveArgs)
|
||||
if saveStatus != 0 {
|
||||
throw CLIError.executionFailed("save command failed: \(saveError)")
|
||||
}
|
||||
|
||||
// 4. Add malicious member to the tar
|
||||
try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename)
|
||||
|
||||
// 5. Remove the image
|
||||
try doRemoveImages(images: [alpineTagged])
|
||||
let imageRemoved = try !isImagePresent(targetImage: alpineTagged)
|
||||
#expect(imageRemoved, "expected image \(alpineTagged) to be removed")
|
||||
|
||||
// 6. Try to load the modified tar with force - should succeed with warning
|
||||
let loadArgs = [
|
||||
"image",
|
||||
"load",
|
||||
"-i",
|
||||
tempFile.path(),
|
||||
"--force",
|
||||
]
|
||||
let (_, _, loadError, loadStatus) = try run(arguments: loadArgs)
|
||||
#expect(loadStatus == 0, "expected load to succeed with force flag")
|
||||
|
||||
// Check that warning was logged about rejected member
|
||||
#expect(loadError.contains("invalid members") || loadError.contains(maliciousFilename), "expected warning about rejected member path")
|
||||
|
||||
// 7. Verify image is loaded
|
||||
let imageLoaded = try isImagePresent(targetImage: alpineTagged)
|
||||
#expect(imageLoaded, "expected image \(alpineTagged) to be loaded")
|
||||
|
||||
// 8. Verify that malicious file was NOT created
|
||||
let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath)
|
||||
#expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)")
|
||||
} catch {
|
||||
Issue.record("failed to test image load with force and invalid members: \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testImageSaveAndLoadStdinStdout() throws {
|
||||
do {
|
||||
// 1. pull image
|
||||
@@ -370,4 +504,41 @@ class TestCLIImagesCommand: CLITest {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws {
|
||||
// Create a malicious entry with path traversal
|
||||
let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)"
|
||||
let evilEntryContent = "pwned\n".data(using: .utf8)!
|
||||
|
||||
// Create a temporary file for the modified tar
|
||||
let tempModifiedTar = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
|
||||
|
||||
// Open the modified tar for writing
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar)
|
||||
|
||||
// First, copy all existing members from the input tar
|
||||
let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath))
|
||||
for (entry, data) in reader {
|
||||
if entry.fileType == .regular {
|
||||
try writer.writeEntry(entry: entry, data: data)
|
||||
} else {
|
||||
try writer.writeEntry(entry: entry, data: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the evil entry
|
||||
let evilEntry = WriteEntry()
|
||||
evilEntry.path = evilEntryName
|
||||
evilEntry.size = Int64(evilEntryContent.count)
|
||||
evilEntry.modificationDate = Date()
|
||||
evilEntry.fileType = .regular
|
||||
evilEntry.permissions = 0o644
|
||||
|
||||
try writer.writeEntry(entry: evilEntry, data: evilEntryContent)
|
||||
try writer.finishEncoding()
|
||||
|
||||
// Replace the original tar with the modified one
|
||||
try FileManager.default.removeItem(atPath: tarPath)
|
||||
try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,12 +504,13 @@ Loads images from a tar archive created by `image save`. The tar file must be sp
|
||||
**Usage**
|
||||
|
||||
```bash
|
||||
container image load --input <input> [--debug]
|
||||
container image load --input <input> [--force] [--debug]
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
* `-i, --input <input>`: Path to the image tar archive
|
||||
* `-f, --force`: Load images even if invalid member files are detected
|
||||
|
||||
### `container image tag`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user