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:
J Logan
2026-01-16 16:26:13 -08:00
committed by GitHub
parent b1577d8d07
commit 744e7f7c7a
15 changed files with 326 additions and 70 deletions
+3 -3
View File
@@ -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
View File
@@ -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",
+6 -3
View File
@@ -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))
}
}
+2 -1
View File
@@ -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`