chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,170 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import ContainerizationError
import ContainerizationExtras
import Testing
@testable import Containerization
struct AllocatorTests {
@Test func allocateDualStackReturnsDistinctPairs() throws {
guard #available(macOS 26, *) else { return }
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("192.168.64.0/24"),
cidrV6: try CIDRv6("fd00::/64"))
let (a4, a6) = try alloc.allocate("a")
let (b4, b6) = try alloc.allocate("b")
#expect(a4 != b4)
#expect(a6 != nil && b6 != nil)
#expect(a6 != b6)
// The v4 allocator starts at lower + 2 (skipping network base + gateway),
// so the first two allocations are .2 and .3.
#expect(a4 == (try CIDRv4("192.168.64.2/24")))
#expect(b4 == (try CIDRv4("192.168.64.3/24")))
}
@Test func allocateWithNoV6PrefixReturnsNilV6() throws {
guard #available(macOS 26, *) else { return }
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("192.168.64.0/24"),
cidrV6: nil)
let (_, a6) = try alloc.allocate("a")
#expect(a6 == nil)
}
@Test func duplicateIdThrows() throws {
guard #available(macOS 26, *) else { return }
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("192.168.64.0/24"),
cidrV6: try CIDRv6("fd00::/64"))
_ = try alloc.allocate("a")
#expect(throws: ContainerizationError.self) {
_ = try alloc.allocate("a")
}
}
@Test func releaseAllowsIdReuse() throws {
guard #available(macOS 26, *) else { return }
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("192.168.64.0/24"),
cidrV6: try CIDRv6("fd00::/64"))
_ = try alloc.allocate("a")
// Re-allocating 'a' would throw .exists if release didn't clear it.
try alloc.release("a")
_ = try alloc.allocate("a")
}
@Test func releaseUnknownIdIsNoOp() throws {
guard #available(macOS 26, *) else { return }
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("192.168.64.0/24"),
cidrV6: try CIDRv6("fd00::/64"))
try alloc.release("never-allocated")
}
@Test func v6HostPortionUsesOrdinalIndex() throws {
guard #available(macOS 26, *) else {
return
}
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("192.168.64.0/24"),
cidrV6: try CIDRv6("fd00::/64"))
let (_, a6) = try alloc.allocate("a")
let (_, b6) = try alloc.allocate("b")
let aHost = a6!.address.value & a6!.prefix.suffixMask128
let bHost = b6!.address.value & b6!.prefix.suffixMask128
#expect(aHost == 2)
#expect(bHost == 3)
}
@Test func cidrV6Gateway() throws {
// The network gateway is the lowest address + 1.
#expect((try CIDRv6("fd00::/64")).gateway == (try IPv6Address("fd00::1")))
#expect((try CIDRv6("fd00:abcd:1234::/48")).gateway == (try IPv6Address("fd00:abcd:1234::1")))
}
@available(macOS 26, *)
private actor SerialAllocator {
private var inner: VmnetNetwork.Allocator
init(cidrV4: CIDRv4, cidrV6: CIDRv6?) throws {
self.inner = try VmnetNetwork.Allocator(cidrV4: cidrV4, cidrV6: cidrV6)
}
func allocate(_ id: String) throws -> (CIDRv4, CIDRv6?) {
try inner.allocate(id)
}
func release(_ id: String) throws {
try inner.release(id)
}
}
@Test func returnsUniqueAddressesUnderConcurrentLoad() async throws {
guard #available(macOS 26, *) else { return }
// /16 host space is much larger than `count`, so we won't hit the
// pool ceiling we're testing for collisions/state corruption.
let alloc = try SerialAllocator(
cidrV4: try CIDRv4("10.0.0.0/16"),
cidrV6: try CIDRv6("fd00::/64"))
let count = 1000
let pairs = try await withThrowingTaskGroup(of: (CIDRv4, CIDRv6?).self) { group in
for i in 0..<count {
group.addTask {
try await alloc.allocate("id-\(i)")
}
}
var collected: [(CIDRv4, CIDRv6?)] = []
for try await pair in group {
collected.append(pair)
}
return collected
}
#expect(pairs.count == count)
let v4s = Set(pairs.map { $0.0 })
let v6s = Set(pairs.compactMap { $0.1 })
#expect(v4s.count == count)
#expect(v6s.count == count)
}
@Test func throwsWhenPoolExhausted() throws {
guard #available(macOS 26, *) else { return }
// /29 has 8 host addresses; allocator capacity is `upper - lower - 3 = 4` slots.
var alloc = try VmnetNetwork.Allocator(
cidrV4: try CIDRv4("10.0.0.0/29"),
cidrV6: try CIDRv6("fd00::/64"))
for i in 0..<4 {
_ = try alloc.allocate("id-\(i)")
}
// The 5th allocation must fail since the v4 pool is drained.
#expect(throws: (any Error).self) {
_ = try alloc.allocate("id-overflow")
}
}
}
#endif
@@ -0,0 +1,60 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 Foundation
import Testing
@testable import Containerization
@Suite("Bridge state file")
struct BridgeStateFileTests {
@Test("round-trip JSON encode/decode (NAT enabled)")
func roundTripNAT() throws {
let s = BridgeState(natEnabled: true, prevIpForward: "0", egressInterface: "eth0")
let data = try s.encode()
let s2 = try BridgeState.decode(data)
#expect(s2.natEnabled == true)
#expect(s2.prevIpForward == "0")
#expect(s2.egressInterface == "eth0")
}
@Test("round-trip JSON encode/decode (NAT disabled)")
func roundTripNoNAT() throws {
let s = BridgeState(natEnabled: false)
let data = try s.encode()
let s2 = try BridgeState.decode(data)
#expect(s2.natEnabled == false)
#expect(s2.prevIpForward == nil)
#expect(s2.egressInterface == nil)
}
@Test("legacy state file without natEnabled defaults to true")
func legacyDefaultsToNATEnabled() throws {
// Pre-flag JSON shape, written by an older version of BridgeManager.
let legacy = #"{"prevIpForward":"0","egressInterface":"eth0"}"#
let s = try BridgeState.decode(Data(legacy.utf8))
#expect(s.natEnabled == true)
#expect(s.prevIpForward == "0")
#expect(s.egressInterface == "eth0")
}
@Test("decode rejects malformed input")
func malformed() {
#expect(throws: (any Error).self) {
_ = try BridgeState.decode(Data("not json".utf8))
}
}
}
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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.
//===----------------------------------------------------------------------===//
#if os(Linux)
import CloudHypervisor
import ContainerizationExtras
import Testing
@testable import Containerization
@Suite("TAPInterface")
struct CHInterfaceTests {
@Test("chNetConfig populates tap, mac, and mtu and leaves IP fields nil")
func chNetConfigShape() throws {
let cidr = try CIDRv4("192.168.64.3/24")
let gateway = try IPv4Address("192.168.64.1")
let mac = try MACAddress("02:42:ac:11:00:02")
let iface = TAPInterface(
tapName: "tap0",
ipv4Address: cidr,
ipv4Gateway: gateway,
macAddress: mac,
mtu: 1500
)
let cfg = try iface.chNetConfig()
#expect(cfg.tap == "tap0")
#expect(cfg.mac == mac.description)
#expect(cfg.mtu == 1500)
#expect(cfg.ip == nil)
#expect(cfg.mask == nil)
#expect(cfg.id == nil)
}
@Test("chNetConfig omits mac when macAddress is nil")
func chNetConfigOmitsMac() throws {
let cidr = try CIDRv4("10.0.0.5/24")
let iface = TAPInterface(tapName: "ch-tap1", ipv4Address: cidr)
let cfg = try iface.chNetConfig()
#expect(cfg.tap == "ch-tap1")
#expect(cfg.mac == nil)
#expect(cfg.mtu == 1500)
}
@Test("TAPInterface satisfies Interface")
func interfaceConformance() throws {
let cidr = try CIDRv4("192.168.64.3/24")
let iface: any Interface = TAPInterface(tapName: "tap0", ipv4Address: cidr)
#expect(iface.ipv4Address == cidr)
#expect(iface.ipv4Gateway == nil)
#expect(iface.macAddress == nil)
#expect(iface.mtu == 1500)
}
}
#endif
@@ -0,0 +1,157 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Containerization
import ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import Foundation
import Testing
@testable import Containerization
private struct NilGatewayInterface: Interface {
let ipv4Address: CIDRv4
let ipv4Gateway: IPv4Address? = nil
let macAddress: MACAddress? = nil
init() {
self.ipv4Address = try! CIDRv4("192.168.64.2/24")
}
}
private struct NilGatewayNetwork: Network {
mutating func createInterface(_ id: String) throws -> Interface? {
NilGatewayInterface()
}
mutating func releaseInterface(_ id: String) throws {}
}
@Suite
struct ContainerManagerTests {
@Test func testCreateThrowsWhenGatewayMissing() async throws {
let fm = FileManager.default
let root = fm.uniqueTemporaryDirectory(create: true)
defer { try? fm.removeItem(at: root) }
let kernelPath = root.appendingPathComponent("vmlinux")
fm.createFile(atPath: kernelPath.path, contents: Data(), attributes: nil)
let initfsPath = root.appendingPathComponent("initfs.ext4")
fm.createFile(atPath: initfsPath.path, contents: Data(), attributes: nil)
let kernel = Kernel(path: kernelPath, platform: .linuxArm)
let initfs = Mount.block(format: "ext4", source: initfsPath.path, destination: "/")
var manager = try ContainerManager(
kernel: kernel,
initfs: initfs,
root: root,
network: NilGatewayNetwork()
)
let tempDir = fm.uniqueTemporaryDirectory()
defer { try? fm.removeItem(at: tempDir) }
let tarPath = Foundation.Bundle.module.url(forResource: "scratch", withExtension: "tar")!
let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
let rejectedPaths = try reader.extractContents(to: tempDir)
#expect(rejectedPaths.isEmpty)
let images = try await manager.imageStore.load(from: tempDir)
let image = images.first!
let rootfsPath = root.appendingPathComponent("rootfs.ext4")
fm.createFile(atPath: rootfsPath.path, contents: Data(), attributes: nil)
let rootfs = Mount.block(format: "ext4", source: rootfsPath.path, destination: "/")
do {
_ = try await manager.create("test-nil-gateway", image: image, rootfs: rootfs) { _ in }
#expect(Bool(false), "expected invalidState error for missing ipv4 gateway")
} catch let error as ContainerizationError {
#expect(error.code == .invalidState)
#expect(error.message.contains("missing ipv4 gateway"))
} catch {
#expect(Bool(false), "unexpected error: \(error)")
}
}
@Test func testNetworkingFalseSkipsInterfaceCreation() async throws {
let fm = FileManager.default
let root = fm.uniqueTemporaryDirectory(create: true)
defer { try? fm.removeItem(at: root) }
let kernelPath = root.appendingPathComponent("vmlinux")
fm.createFile(atPath: kernelPath.path, contents: Data(), attributes: nil)
let initfsPath = root.appendingPathComponent("initfs.ext4")
fm.createFile(atPath: initfsPath.path, contents: Data(), attributes: nil)
let kernel = Kernel(path: kernelPath, platform: .linuxArm)
let initfs = Mount.block(format: "ext4", source: initfsPath.path, destination: "/")
// Use NilGatewayNetwork with networking: true this would throw invalidState,
// but with networking: false the network's createInterface() is never called.
var manager = try ContainerManager(
kernel: kernel,
initfs: initfs,
root: root,
network: NilGatewayNetwork()
)
let tempDir = fm.uniqueTemporaryDirectory()
defer { try? fm.removeItem(at: tempDir) }
let tarPath = Foundation.Bundle.module.url(forResource: "scratch", withExtension: "tar")!
let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
let rejectedPaths = try reader.extractContents(to: tempDir)
#expect(rejectedPaths.isEmpty)
let images = try await manager.imageStore.load(from: tempDir)
let image = images.first!
let rootfsPath = root.appendingPathComponent("rootfs.ext4")
fm.createFile(atPath: rootfsPath.path, contents: Data(), attributes: nil)
let rootfs = Mount.block(format: "ext4", source: rootfsPath.path, destination: "/")
// With networking: false, NilGatewayNetwork.createInterface() is never called,
// so we should not get the "missing ipv4 gateway" error.
// The container creation will fail for other reasons (dummy VMM), but the
// configuration closure should see empty interfaces.
var closureWasCalled = false
do {
_ = try await manager.create(
"test-no-networking",
image: image,
rootfs: rootfs,
networking: false
) { config in
closureWasCalled = true
#expect(config.interfaces.isEmpty)
#expect(config.dns == nil)
}
} catch {
// Container creation may fail due to dummy kernel/VMM that's expected.
// The key assertion is in the configuration closure above.
let description = String(describing: error)
#expect(!description.contains("missing ipv4 gateway"))
}
#expect(closureWasCalled, "configuration closure must be invoked to validate interfaces")
}
}
#endif
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Foundation
import Testing
@testable import Containerization
struct DNSTests {
@Test func dnsResolvConfWithAllFields() {
let dns = DNS(
nameservers: ["8.8.8.8", "1.1.1.1"],
domain: "example.com",
searchDomains: ["internal.com", "test.com"],
options: ["ndots:2", "timeout:1"]
)
let expected = "nameserver 8.8.8.8\nnameserver 1.1.1.1\ndomain example.com\nsearch internal.com test.com\noptions ndots:2 timeout:1\n"
#expect(dns.resolvConf == expected)
}
@Test func dnsResolvConfWithEmptyFields() {
let dns = DNS(
nameservers: [],
domain: nil,
searchDomains: [],
options: []
)
// Should return empty string when all fields are empty
#expect(dns.resolvConf == "")
}
@Test func dnsResolvConfWithOnlyNameservers() {
let dns = DNS(nameservers: ["8.8.8.8"])
let expected = "nameserver 8.8.8.8\n"
#expect(dns.resolvConf == expected)
}
@Test func dnsValidateAcceptsValidIPv4Nameservers() throws {
let dns = DNS(nameservers: ["8.8.8.8", "1.1.1.1"])
#expect(throws: Never.self) { try dns.validate() }
}
@Test func dnsValidateAcceptsValidIPv6Nameservers() throws {
let dns = DNS(nameservers: ["2001:4860:4860::8888", "::1"])
#expect(throws: Never.self) { try dns.validate() }
}
@Test func dnsValidateAcceptsMixedIPv4AndIPv6Nameservers() throws {
let dns = DNS(nameservers: ["8.8.8.8", "2001:4860:4860::8844"])
#expect(throws: Never.self) { try dns.validate() }
}
@Test func dnsValidateAcceptsEmptyNameservers() throws {
let dns = DNS(nameservers: [])
#expect(throws: Never.self) { try dns.validate() }
}
@Test func dnsValidateRejectsHostname() {
let dns = DNS(nameservers: ["dns.example.com"])
#expect(throws: (any Error).self) { try dns.validate() }
}
@Test func dnsValidateRejectsInvalidAddress() {
let dns = DNS(nameservers: ["not-an-ip"])
#expect(throws: (any Error).self) { try dns.validate() }
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Foundation
import Testing
@testable import Containerization
struct HashTests {
@Test func hashMountSourceWithValidString() throws {
let result = try hashFilePath(path: "/valid/path")
// Should produce a non-empty hash
#expect(!result.isEmpty)
// Same input should produce same hash (deterministic)
let result2 = try hashFilePath(path: "/valid/path")
#expect(result == result2)
// Different inputs should produce different hashes
let result3 = try hashFilePath(path: "/different/path")
#expect(result != result3)
}
}
@@ -0,0 +1,66 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 Testing
@testable import Containerization
@Suite("Host default route parsing")
struct HostDefaultRouteTests {
// Header + one default-route row (Destination=00000000, Flags=0003 has RTF_GATEWAY=0x2).
static let singleDefault = """
Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT
eth0\t00000000\t0102A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0
eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\tFFFFFF00\t0\t0\t0
"""
static let noDefault = """
Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT
eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\tFFFFFF00\t0\t0\t0
"""
static let multiDefault = """
Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT
wlan0\t00000000\t0102A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0
eth0\t00000000\t0102A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0
"""
@Test("returns iface for a single default route")
func singleDefaultRoute() {
#expect(HostDefaultRoute.parseEgress(procNetRoute: Self.singleDefault) == "eth0")
}
@Test("returns nil when no default route")
func noDefaultRoute() {
#expect(HostDefaultRoute.parseEgress(procNetRoute: Self.noDefault) == nil)
}
@Test("returns lowest-metric default when multiple")
func multipleDefaultRoutes() {
// eth0 has metric 100; wlan0 has metric 600.
#expect(HostDefaultRoute.parseEgress(procNetRoute: Self.multiDefault) == "eth0")
}
@Test("ignores rows missing RTF_GATEWAY flag")
func noGatewayFlag() {
// Same as singleDefault but flags=0001 (no RTF_GATEWAY=0x2).
let input = """
Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT
eth0\t00000000\t0102A8C0\t0001\t0\t0\t0\t00000000\t0\t0\t0
"""
#expect(HostDefaultRoute.parseEgress(procNetRoute: input) == nil)
}
}
@@ -0,0 +1,68 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Foundation
import Testing
@testable import Containerization
struct HostsTests {
@Test func hostsEntryRenderedWithAllFields() {
let entry = Hosts.Entry(
ipAddress: "192.168.1.100",
hostnames: ["myserver", "server.local"],
comment: "My local server"
)
let expected = "192.168.1.100 myserver server.local # My local server "
#expect(entry.rendered == expected)
}
@Test func hostsEntryRenderedWithoutComment() {
let entry = Hosts.Entry(
ipAddress: "10.0.0.1",
hostnames: ["gateway"]
)
let expected = "10.0.0.1 gateway"
#expect(entry.rendered == expected)
}
@Test func hostsEntryRenderedWithEmptyHostnames() {
let entry = Hosts.Entry(
ipAddress: "172.16.0.1",
hostnames: [],
comment: "Empty hostnames"
)
let expected = "172.16.0.1 # Empty hostnames "
#expect(entry.rendered == expected)
}
@Test func hostsFileWithCommentAndEntries() {
let hosts = Hosts(
entries: [
Hosts.Entry(ipAddress: "127.0.0.1", hostnames: ["localhost"]),
Hosts.Entry(ipAddress: "192.168.1.10", hostnames: ["server"], comment: "Main server"),
],
comment: "Generated hosts file"
)
let expected = "# Generated hosts file\n127.0.0.1 localhost\n192.168.1.10 server # Main server \n"
#expect(hosts.hostsFile == expected)
}
}
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationOCI
import Foundation
import Testing
@testable import Containerization
struct ImageTests {
@Test func imageDescriptionComputedProperties() {
let descriptor = Descriptor(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: "sha256:abc123def456",
size: 1024
)
let description = Image.Description(reference: "myapp:latest", descriptor: descriptor)
#expect(description.digest == "sha256:abc123def456")
#expect(description.mediaType == "application/vnd.oci.image.manifest.v1+json")
#expect(description.reference == "myapp:latest")
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationOCI
import Foundation
internal protocol ContainsAuth {
}
extension ContainsAuth {
static var hasRegistryCredentials: Bool {
authentication != nil
}
static var authentication: Authentication? {
let env = ProcessInfo.processInfo.environment
guard let password = env["REGISTRY_TOKEN"],
let username = env["REGISTRY_USERNAME"]
else {
return nil
}
return BasicAuthentication(username: username, password: password)
}
}
@@ -0,0 +1,120 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 ContainerizationExtras
import ContainerizationOCI
import Crypto
import Foundation
import NIO
import Testing
@testable import Containerization
@Suite
final class ExportOperationTests {
@Test(arguments: [MediaTypes.dockerManifestList, MediaTypes.index])
func testIndexPushMediaTypeMatchesBody(_ sourceMediaType: String) async throws {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
defer { try? FileManager.default.removeItem(at: dir) }
let cs = try LocalContentStore(path: dir)
// Opaque child mediaType so ExportOperation's recursion stops here
// and we don't have to seed config/layer blobs.
let opaqueType = "application/vnd.test.opaque.v1+json"
let childData = Data("child-amd64".utf8)
let childDigest = SHA256.hash(data: childData).digestString
let childDesc = Descriptor(
mediaType: opaqueType,
digest: childDigest,
size: Int64(childData.count),
platform: Platform(arch: "amd64", os: "linux"))
let index = Index(mediaType: sourceMediaType, manifests: [childDesc])
let indexData = try JSONEncoder().encode(index)
let indexDigest = SHA256.hash(data: indexData).digestString
try await cs.ingest { ingestDir in
for (digest, data) in [(childDigest, childData), (indexDigest, indexData)] {
let path = ingestDir.appendingPathComponent(digest.trimmingDigestPrefix)
try data.write(to: path)
}
}
let indexDesc = Descriptor(
mediaType: sourceMediaType,
digest: indexDigest,
size: Int64(indexData.count))
let capture = CapturingContentClient()
let op = ImageStore.ExportOperation(
name: "test/repo", tag: "v1", contentStore: cs, client: capture)
let pushed = try await op.export(index: indexDesc, platforms: { _ in true })
#expect(pushed.mediaType == sourceMediaType)
let indexPush = try #require(
capture.pushes.first(where: { $0.descriptor.digest == pushed.digest }))
#expect(indexPush.descriptor.mediaType == sourceMediaType)
let pushedIndex = try JSONDecoder().decode(Index.self, from: indexPush.body)
#expect(pushedIndex.mediaType == sourceMediaType)
}
}
private final class CapturingContentClient: ContentClient, @unchecked Sendable {
struct Push: Sendable {
let descriptor: Descriptor
let body: Data
}
private let lock = NSLock()
private var _pushes: [Push] = []
var pushes: [Push] {
lock.withLock { _pushes }
}
private struct NotImplemented: Error {}
func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
throw NotImplemented()
}
func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
throw NotImplemented()
}
func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
throw NotImplemented()
}
func push<T: Sendable & AsyncSequence>(
name: String,
ref: String,
descriptor: Descriptor,
streamGenerator: () throws -> T,
progress: ProgressHandler?
) async throws where T.Element == ByteBuffer {
let stream = try streamGenerator()
var data = Data()
for try await buf in stream {
data.append(contentsOf: buf.readableBytesView)
}
lock.withLock {
_pushes.append(Push(descriptor: descriptor, body: data))
}
}
}
@@ -0,0 +1,176 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import ContainerizationArchive
import ContainerizationEXT4
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import SystemPackage
import Testing
@testable import Containerization
/// Measures header scan overhead vs. full unpack time using real container images
/// pulled from a registry.
///
/// Run with:
/// ENABLE_TIMING_TESTS=1 swift test --filter ImageHeaderScanTimingTest
@Suite
struct ImageHeaderScanTimingTest {
private static let isEnabled = ProcessInfo.processInfo.environment["ENABLE_TIMING_TESTS"] != nil
let store: ImageStore
let dir: URL
let contentStore: ContentStore
init() throws {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
let cs = try LocalContentStore(path: dir)
let store = try ImageStore(path: dir, contentStore: cs)
self.dir = dir
self.store = store
self.contentStore = cs
}
@Test(.enabled(if: ImageHeaderScanTimingTest.isEnabled))
func measureAlpineAndUbuntu() async throws {
defer { try? FileManager.default.removeItem(at: dir) }
let images: [(reference: String, label: String)] = [
("ghcr.io/linuxcontainers/alpine:3.20", "Alpine 3.20"),
("docker.io/library/ubuntu:24.04", "Ubuntu 24.04"),
]
for image in images {
print("\n==============================")
print("Image: \(image.label)")
print("==============================")
let img = try await store.pull(reference: image.reference, platform: .current)
let manifest = try await img.manifest(for: .current)
for (i, layer) in manifest.layers.enumerated() {
let content = try await img.getContent(digest: layer.digest)
let compression = compressionFilter(for: layer.mediaType)
let compressedSize = try FileManager.default.attributesOfItem(atPath: content.path.path)[.size] as? Int64 ?? 0
let label = "\(image.label) layer \(i + 1)/\(manifest.layers.count) (\(layer.mediaType), \(formatBytes(compressedSize)) compressed)"
try await measureOverhead(url: content.path, compression: compression, label: label)
}
}
}
// MARK: - Helpers
private func compressionFilter(for mediaType: String) -> ContainerizationArchive.Filter {
switch mediaType {
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
return .zstd
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
return .none
default:
return .gzip
}
}
private func measureOverhead(url: URL, compression: ContainerizationArchive.Filter, label: String) async throws {
let clock = ContinuousClock()
print("\n--- \(label) ---\n")
// For zstd, pre-decompress once (matching the production code path in EXT4Unpacker).
let scanFile: URL
let scanFilter: ContainerizationArchive.Filter
var decompressedFile: URL?
if compression == .zstd {
var decompressed: URL = url
let decompressDuration = try clock.measure {
decompressed = try ArchiveReader.decompressZstd(url)
}
scanFile = decompressed
scanFilter = .none
decompressedFile = decompressed
print(" Zstd decompress: \(decompressDuration)")
} else {
scanFile = url
scanFilter = compression
}
defer {
if let decompressedFile {
ArchiveReader.cleanUpDecompressedZstd(decompressedFile)
}
}
// 1. Header scan only
var scannedTotals: (size: Int64, items: Int) = (0, 0)
let scanDuration = try clock.measure {
scannedTotals = try EXT4.Formatter.scanArchiveHeaders(
format: .paxRestricted, filter: scanFilter, file: scanFile)
}
print(" Scanned total size: \(formatBytes(scannedTotals.size)) (\(scannedTotals.items) items)")
print(" Header scan: \(scanDuration)")
// 2. Full unpack without progress
let tempDir1 = FileManager.default.uniqueTemporaryDirectory()
let fsPath1 = FilePath(tempDir1.appendingPathComponent("no-progress.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir1) }
let unpackOnlyDuration = try await clock.measure {
let formatter = try EXT4.Formatter(fsPath1)
try await formatter.unpack(source: url, compression: compression)
try formatter.close()
}
print(" Unpack (no progress): \(unpackOnlyDuration)")
// 3. Full unpack with progress (includes header scan pass)
let tempDir2 = FileManager.default.uniqueTemporaryDirectory()
let fsPath2 = FilePath(tempDir2.appendingPathComponent("with-progress.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir2) }
let noopProgress: ProgressHandler = { _ in }
let withProgressDuration = try await clock.measure {
let formatter = try EXT4.Formatter(fsPath2)
try await formatter.unpack(source: url, compression: compression, progress: noopProgress)
try formatter.close()
}
print(" Unpack (w/ progress): \(withProgressDuration)")
// Summary
let scanMs = toMs(scanDuration)
let unpackMs = toMs(unpackOnlyDuration)
let withProgressMs = toMs(withProgressDuration)
let overheadMs = withProgressMs - unpackMs
let overheadPct = unpackMs > 0 ? (overheadMs / unpackMs) * 100 : 0
print("\n Summary:")
print(" Header scan alone: \(String(format: "%.1f", scanMs)) ms")
print(" Unpack only: \(String(format: "%.1f", unpackMs)) ms")
print(" Unpack + progress: \(String(format: "%.1f", withProgressMs)) ms")
print(" Overhead: \(String(format: "%.1f", overheadMs)) ms (\(String(format: "%.1f", overheadPct))%)")
}
private func toMs(_ d: Duration) -> Double {
let c = d.components
return Double(c.seconds) * 1000.0 + Double(c.attoseconds) / 1e15
}
private func formatBytes(_ bytes: Int64) -> String {
let mb = Double(bytes) / 1_048_576.0
return "\(String(format: "%.1f", mb)) MB"
}
}
#endif
@@ -0,0 +1,175 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationOCI
import Crypto
import Foundation
import NIO
import Testing
@testable import Containerization
@Suite
final class ImageStoreImagePullTests {
let store: ImageStore
let dir: URL
let contentStore: ContentStore
public init() {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
let cs = try! LocalContentStore(path: dir)
let store = try! ImageStore(path: dir, contentStore: cs)
self.dir = dir
self.store = store
self.contentStore = cs
}
deinit {
try! FileManager.default.removeItem(at: self.dir)
}
@Test func testPullImageWithoutIndex() async throws {
let img = try await self.store.pull(reference: "ghcr.io/apple/containerization/dockermanifestimage:0.0.2")
let rootDescriptor = img.descriptor
let index: ContainerizationOCI.Index = try await contentStore.get(digest: rootDescriptor.digest)!
#expect(index.manifests.count == 1)
let desc = index.manifests.first!
#expect(desc.platform!.architecture == "amd64")
await #expect(throws: Never.self) {
let manifest: ContainerizationOCI.Manifest = try await self.contentStore.get(digest: desc.digest)!
let _: ContainerizationOCI.Image = try await self.contentStore.get(digest: manifest.config.digest)!
for layer in manifest.layers {
_ = try await self.contentStore.get(digest: layer.digest)!
}
}
}
@Test(
arguments: [
(Platform(arch: "arm64", os: "linux", variant: "v8"), imagePullArm64Layers),
(Platform(arch: "amd64", os: "linux"), imagePullAmd64Layers),
(nil, imagePullTestAllLayers),
])
func testPullSinglePlatform(platform: Platform?, expectLayers: [String]) async throws {
let img = try await self.store.pull(
reference: "ghcr.io/linuxcontainers/alpine:3.20@sha256:0a6a86d44d7f93c4f2b8dea7f0eee64e72cb98635398779f3610949632508d57", platform: platform)
let rootDescriptor = img.descriptor
let index: ContainerizationOCI.Index = try await contentStore.get(digest: rootDescriptor.digest)!
var foundMatch = false
for desc in index.manifests {
if let platform {
if desc.platform != platform {
continue
}
}
foundMatch = true
await #expect(throws: Never.self) {
let manifest: ContainerizationOCI.Manifest = try await self.contentStore.get(digest: desc.digest)!
let _: ContainerizationOCI.Image = try await self.contentStore.get(digest: manifest.config.digest)!
for layer in manifest.layers {
_ = try await self.contentStore.get(digest: layer.digest)!
}
}
}
#expect(foundMatch)
let contentPath = dir.appendingPathComponent("blobs/sha256")
let filesOnDisk = try FileManager.default.contentsOfDirectory(at: contentPath, includingPropertiesForKeys: nil).map {
$0.lastPathComponent
}.sorted()
#expect(filesOnDisk == expectLayers)
}
@Test func testPullWithSha() async throws {
let sha = "sha256:0a6a86d44d7f93c4f2b8dea7f0eee64e72cb98635398779f3610949632508d57"
let r = "ghcr.io/linuxcontainers/alpine:3.20@\(sha)"
let img = try await self.store.pull(reference: r, platform: .current)
#expect(img.descriptor.digest == sha)
}
}
let imagePullTestAllLayers = [
"013c522f9494ecda30dc8fbee7805b59c773573fd080c74e6835def22547bd07",
"037316e2a3a13e6d7e15057d3ede6ad063f15c92216778576ee88a74e6f7c6fc",
"0566dbe8e93e20dbfebc6b023399a6eb337719faf1d11dab57f975286c198a00",
"0928a8adc0d420ddda0d25c76e95282534a5b69b13ffcbb6ddbc41c50fc77550",
"0a6a86d44d7f93c4f2b8dea7f0eee64e72cb98635398779f3610949632508d57",
"0a9a5dfd008f05ebc27e4790db0709a29e527690c21bcbcd01481eaeb6bb49dc",
"0c11ea92e0d923e7812d258defbe6788642547fa347969a3dbd7bb7cbc0a9666",
"156724324a3250a38177b0328390d7efb4ba85d7e095ad7af9ca19a3cd46f855",
"1c2a87b1633d21ffcd8192bc84f9bed0c479bbcfbcd8b76b9ca1b8bf8bc61516",
"2803bd9fd5a5e53bc39c576b3e7eaf4839ec77dcc1274c6ad9f7d534ccc566c7",
"2d2e65d21b1f1a7cf14b99e54809bb4eee749fa9145d1e263279e18e246e5e1b",
"34bac5d0022b2997fdfd5c678521d6afe58a4ea6c65d5d31e3ece0be141158ea",
"3e6ec69548a14d7bf37b242f02f26dd41c69e9c510225078ca1f241ef249b3df",
"423949aec9a2fe60140a59926634f90979ac19878957becf9902dcc547592a44",
"4817c12fc96d333e818e9f56f22a7c8683bd3ca8b0c04ce45e188dc6aaf8e5c3",
"4e32c214e82a5d6ccb62b58fb42405fc961c69da5fe02c670f1e4c62c8eb6fbb",
"4ea6a163031004a9a61288b7a5ffbf73d84115d398abe5180caeb15442d1a5fe",
"4f0bb7ea5efffa5762fc231a403f232ca3ea43ef6db18d4bf52aeca8c15d7dec",
"55a8c211d2e969b7b7e9e4825853cf24a75cbbcaf7728db15840c1514838a23d",
"5c979effb79226e255a01eaeb2a525bd12019c02eba2b76f6e0726dd2701508e",
"63e2abc26a64dea41796995524777edc558e143bea4929f06954c52706363f33",
"6d8b5334139bdee0462dd4d6cc85fdec98ad4d97155075973432ec4ff67906c9",
"6f3c7dfa949497fb255d0a28c244e7add0d52ae6318b45947a8a2940d846b2e2",
"718fbe9a22ec3da853bdbd5d8112f2dc8ba41f30d46899b3792242f16a0f8b41",
"744d40c360fd0988b20b15ab845d3db943817b027f38ea6850361bab4ac916be",
"76a0ff976fd7cf0f21858535989ef59ac2ee64a3f1bf1b68d98d15138cd46afa",
"772078ddbdee5be52d429e08f953aaad6715a90d7e4d6496eb1cd4004efa8a95",
"7c6bf3be7c8016421fb3033e19b6a313f264093e1ac9e77c9f931ade0d61b3f7",
"7f608f0a59b5b3717cdd3cc61ef59c329d3c2c16c5fc6963b3b13360d43841c0",
"81fc5885a3ad37110bf576934de28326e1194bd943e020bc3924502335fdf181",
"84df3263e35ed35440625ae0ebb5b1c3d00f57ffcec61188015d5217988a8b35",
"85b46e4c8e4841ce7964ce897a07a4d9df7d589322593fb600dd428e47d635ae",
"872bd582507dfe35ccd496fcd128f61963620053a722b517353f3d9df46412e9",
"8a9fb51ac81600da44afb1c4a5df4745d23eca0cc5d924f989c074f3da7a9440",
"90bb43c8fe064682d965fe27b1ca0cf2b42cf0273914cc20a4e636e174ccdaf5",
"9368b67dac9dc00ca8dbbd25b6f148fd6229b01dac5ff3d89281bd296cf196c6",
"94e9d8af22013aabf0edcaf42950c88b0a1350c3a9ce076d61b98a535a673dd9",
"9cbaf16e9229ef1466c71cf97a75ddd7d2041522012dd1bcace0d56a9ad77688",
"9cfe406db828239417e29e2c00bdf196c32b39ffcead4c2e28cdf60ff8a8dd58",
"adc8e49a814d3e4f73ccdd1d26d4cbd3f1a338b4e136a55c092bdcca57863225",
"b1ca1bb0a5f203b48e1ca60861ae852f49b910ce8488c19c392a3bc7ee31b072",
"b3d7db73e90671cb6b7925cc878d43a2781451bed256cf0626110f5386cdd4dc",
"bfc9829f240e42bab6b756c64179b8e73317baf0e9a8940ea1571cb2f29efcc3",
"c70d93f05189a8a6a10ba5657b8e89e849f2c7491d76587178f75d9fca228bf1",
"c9813c0f5a2f289ea6175876fd973d6d8adcd495da4a23e9273600c8f0a761c5",
"c9aedc9d4e47fa9429e5c329420d8a93e16c433e361d0f9281565ed4da3c057e",
"d27e7628ef6e28a3e91cdc1ef1f998a703d356067ecedddfe9e9281e36d8c9f9",
"ef99f4640fe11015a03439935b827bff242d0db64db27db005a31ab4497db4a2",
"f2c7f3c3fecbf01204eccd798e2f77b0003a8567927a5d6242fd3ed81727fee9",
"f882dda529d0cd4b586a10a7f60048c7f8faaff26d6672008d0478b8b004bc63",
]
let imagePullArm64Layers = [
"0a6a86d44d7f93c4f2b8dea7f0eee64e72cb98635398779f3610949632508d57",
"3e6ec69548a14d7bf37b242f02f26dd41c69e9c510225078ca1f241ef249b3df",
"423949aec9a2fe60140a59926634f90979ac19878957becf9902dcc547592a44",
"76a0ff976fd7cf0f21858535989ef59ac2ee64a3f1bf1b68d98d15138cd46afa",
"94e9d8af22013aabf0edcaf42950c88b0a1350c3a9ce076d61b98a535a673dd9",
]
let imagePullAmd64Layers = [
"0a6a86d44d7f93c4f2b8dea7f0eee64e72cb98635398779f3610949632508d57",
"0a9a5dfd008f05ebc27e4790db0709a29e527690c21bcbcd01481eaeb6bb49dc",
"156724324a3250a38177b0328390d7efb4ba85d7e095ad7af9ca19a3cd46f855",
"1c2a87b1633d21ffcd8192bc84f9bed0c479bbcfbcd8b76b9ca1b8bf8bc61516",
"4ea6a163031004a9a61288b7a5ffbf73d84115d398abe5180caeb15442d1a5fe",
]
@@ -0,0 +1,130 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationArchive
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import Testing
@testable import Containerization
@Suite
public class ImageStoreTests: ContainsAuth {
let store: ImageStore
let dir: URL
public init() {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
let cs = try! LocalContentStore(path: dir)
let store = try! ImageStore(path: dir, contentStore: cs)
self.dir = dir
self.store = store
}
deinit {
try! FileManager.default.removeItem(at: self.dir)
}
@Test func testImageStoreOperation() async throws {
let fileManager = FileManager.default
let tempDir = fileManager.uniqueTemporaryDirectory()
defer {
try? fileManager.removeItem(at: tempDir)
}
let tarPath = Foundation.Bundle.module.url(forResource: "scratch", withExtension: "tar")!
let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
let rejectedPaths = try reader.extractContents(to: tempDir)
#expect(rejectedPaths.count == 0, "unexpected rejected paths [\(rejectedPaths)]")
let _ = try await self.store.load(from: tempDir)
let loaded = try await self.store.load(from: tempDir)
let expectedLoadedImage = "registry.local/integration-tests/scratch:latest"
#expect(loaded.first!.reference == "registry.local/integration-tests/scratch:latest")
guard let authentication = Self.authentication else {
return
}
let imageReference = "ghcr.io/apple/containerization/dockermanifestimage:0.0.2"
let busyboxImage = try await self.store.pull(reference: imageReference, auth: authentication)
let got = try await self.store.get(reference: imageReference)
#expect(got.descriptor == busyboxImage.descriptor)
let newTag = "registry.local/integration-tests/dockermanifestimage:latest"
let _ = try await self.store.tag(existing: imageReference, new: newTag)
let tempFile = self.dir.appending(path: "export.tar")
try await self.store.save(references: [imageReference, expectedLoadedImage], out: tempFile)
}
@Test(.disabled("External users cannot push images, disable while we find a better solution"))
func testImageStorePush() async throws {
guard let authentication = Self.authentication else {
return
}
let imageReference = "ghcr.io/apple/containerization/dockermanifestimage:0.0.2"
let remoteImageName = "ghcr.io/apple/test-images/image-push"
let epoch = Int(Date().timeIntervalSince1970.description)
let tag = epoch != nil ? String(epoch!) : "latest"
let upstreamTag = "\(remoteImageName):\(tag)"
let _ = try await self.store.tag(existing: imageReference, new: upstreamTag)
try await self.store.push(reference: upstreamTag, auth: authentication)
}
@Test(.disabled("External users cannot push images, disable while we find a better solution"))
func testImageStorePushMultipleReferences() async throws {
guard let authentication = Self.authentication else {
return
}
let imageReference = "ghcr.io/apple/containerization/dockermanifestimage:0.0.2"
let remoteImageName = "ghcr.io/apple/test-images/image-push"
let epoch = Int(Date().timeIntervalSince1970)
let tags = ["\(remoteImageName):\(epoch)-a", "\(remoteImageName):\(epoch)-b", "\(remoteImageName):\(epoch)-c"]
for tag in tags {
let _ = try await self.store.tag(existing: imageReference, new: tag)
}
try await self.store.push(references: tags, auth: authentication, maxConcurrentUploads: 2)
}
@Test func testLoadImageWithoutAnnotations() async throws {
let fileManager = FileManager.default
let tempDir = fileManager.uniqueTemporaryDirectory()
defer {
try? fileManager.removeItem(at: tempDir)
}
let tarPath = Foundation.Bundle.module.url(forResource: "scratch_no_annotations", withExtension: "tar")!
let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
let rejectedPaths = try reader.extractContents(to: tempDir)
#expect(rejectedPaths.count == 0, "unexpected rejected paths [\(rejectedPaths)]")
let loaded = try await self.store.load(from: tempDir)
#expect(loaded.count == 1)
let reference = loaded.first!.reference
#expect(reference.hasPrefix("untagged@sha256:"))
let retrieved = try await self.store.get(reference: reference)
#expect(retrieved.reference == reference)
}
}
@@ -0,0 +1,59 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 ContainerizationExtras
import Testing
@testable import Containerization
struct InterfaceTests {
/// A minimal `Interface` conformer that only sets the IPv4 surface, relying on the
/// protocol's default extensions to fill in `ipv6Address`, `ipv6Gateway`, and `mtu`.
private struct V4OnlyInterface: Interface {
let ipv4Address: CIDRv4
let ipv4Gateway: IPv4Address?
let macAddress: MACAddress?
}
@Test func interfaceProtocolV6Defaults() throws {
let i = V4OnlyInterface(
ipv4Address: try CIDRv4("10.0.0.2/24"),
ipv4Gateway: try IPv4Address("10.0.0.1"),
macAddress: nil)
#expect(i.ipv6Address == nil)
#expect(i.ipv6Gateway == nil)
#expect(i.mtu == 1500)
}
@Test func natInterfaceRoundTripsV6Fields() throws {
let nat = NATInterface(
ipv4Address: try CIDRv4("10.0.0.2/24"),
ipv4Gateway: try IPv4Address("10.0.0.1"),
ipv6Address: try CIDRv6("fd00::2/64"),
ipv6Gateway: try IPv6Address("fd00::1"))
#expect(nat.ipv6Address == (try CIDRv6("fd00::2/64")))
#expect(nat.ipv6Gateway == (try IPv6Address("fd00::1")))
}
@Test func natInterfaceV6FieldsDefaultToNil() throws {
let nat = NATInterface(
ipv4Address: try CIDRv4("10.0.0.2/24"),
ipv4Gateway: try IPv4Address("10.0.0.1"))
#expect(nat.ipv6Address == nil)
#expect(nat.ipv6Gateway == nil)
}
}
@@ -0,0 +1,93 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Foundation
import Logging
import Testing
@testable import Containerization
final class KernelTests {
@Test func kernelArgs() {
let commandLine = Kernel.CommandLine(debug: false, panic: 0)
let kernel = Kernel(path: .init(fileURLWithPath: ""), platform: .linuxArm, commandline: commandLine)
let expected = "console=hvc0 tsc=reliable panic=0"
let cmdline = kernel.commandLine.kernelArgs.joined(separator: " ")
#expect(cmdline == expected)
}
@Test func kernelDebugArgs() {
let cmdLine = Kernel.CommandLine(debug: true, panic: 0)
let kernel = Kernel(path: .init(fileURLWithPath: ""), platform: .linuxArm, commandline: cmdLine)
let expected = "console=hvc0 tsc=reliable debug panic=0"
let cmdline = kernel.commandLine.kernelArgs.joined(separator: " ")
#expect(cmdline == expected)
}
@Test func kernelCommandLineInitWithDebugTrue() {
let commandLine = Kernel.CommandLine(debug: true, panic: 5, initArgs: ["--verbose"])
#expect(commandLine.kernelArgs == ["console=hvc0", "tsc=reliable", "debug", "panic=5"])
#expect(commandLine.initArgs == ["--verbose"])
}
@Test func kernelCommandLineMutatingMethods() {
var commandLine = Kernel.CommandLine(kernelArgs: ["console=hvc0"], initArgs: [])
commandLine.addDebug()
commandLine.addPanic(level: 10)
#expect(commandLine.kernelArgs == ["console=hvc0", "debug", "panic=10"])
}
@Test func setAgentLogLevelAppendsFlagAndValue() {
var commandLine = Kernel.CommandLine(initArgs: [])
commandLine.setAgentLogLevel(level: .debug)
#expect(commandLine.initArgs == ["--log-level", "debug"])
}
@Test(arguments: [
(Logger.Level.trace, "trace"),
(.debug, "debug"),
(.info, "info"),
(.notice, "notice"),
(.warning, "warning"),
(.error, "error"),
(.critical, "critical"),
])
func setAgentLogLevelForEachLevel(level: Logger.Level, expected: String) {
var commandLine = Kernel.CommandLine(initArgs: [])
commandLine.setAgentLogLevel(level: level)
#expect(commandLine.initArgs == ["--log-level", expected])
}
@Test func setAgentLogLevelPreservesExistingInitArgs() {
var commandLine = Kernel.CommandLine(initArgs: ["--verbose"])
commandLine.setAgentLogLevel(level: .info)
#expect(commandLine.initArgs == ["--verbose", "--log-level", "info"])
}
@Test func setAgentLogLevelDoesNotAffectKernelArgs() {
var commandLine = Kernel.CommandLine(debug: true, panic: 0, initArgs: [])
let kernelArgsBefore = commandLine.kernelArgs
commandLine.setAgentLogLevel(level: .warning)
#expect(commandLine.kernelArgs == kernelArgsBefore)
}
}
@@ -0,0 +1,122 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationOCI
import ContainerizationOS
import Foundation
import Testing
@testable import Containerization
struct LinuxContainerTests {
@Test func processInitFromImageConfigWithAllFields() {
let imageConfig = ImageConfig(
user: "appuser",
env: ["NODE_ENV=production", "PORT=3000"],
entrypoint: ["/usr/bin/node"],
cmd: ["app.js", "--verbose"],
workingDir: "/app"
)
let process = LinuxProcessConfiguration(from: imageConfig)
#expect(process.workingDirectory == "/app")
#expect(process.environmentVariables == ["NODE_ENV=production", "PORT=3000"])
#expect(process.arguments == ["/usr/bin/node", "app.js", "--verbose"])
#expect(process.user.username == "appuser")
}
@Test func processInitFromImageConfigWithNilValues() {
let imageConfig = ImageConfig(
user: nil,
env: nil,
entrypoint: nil,
cmd: nil,
workingDir: nil
)
let process = LinuxProcessConfiguration(from: imageConfig)
#expect(process.workingDirectory == "/")
#expect(process.environmentVariables == [])
#expect(process.arguments == [])
#expect(process.user.username == "") // Default User() has empty string username
}
@Test func processInitFromImageConfigEntrypointAndCmdConcatenation() {
let imageConfig = ImageConfig(
entrypoint: ["/bin/sh", "-c"],
cmd: ["echo 'hello'", "&&", "sleep 10"]
)
let process = LinuxProcessConfiguration(from: imageConfig)
#expect(process.arguments == ["/bin/sh", "-c", "echo 'hello'", "&&", "sleep 10"])
}
@Test func defaultCapabilitiesAreRestrictedOCISet() {
// Regression guard against shipping `.allCapabilities` as the default.
// A default container must not receive CAP_SYS_ADMIN, which would let it
// write /proc/sys/kernel/core_pattern and escape to guest-root. Cover both
// construction paths: the no-argument init (property default) and the full
// memberwise init (parameter default).
let viaProperty = LinuxProcessConfiguration()
let viaInit = LinuxProcessConfiguration(arguments: ["/bin/sh"])
for caps in [viaProperty.capabilities, viaInit.capabilities] {
for set in [caps.bounding, caps.effective, caps.permitted, caps.inheritable, caps.ambient] {
#expect(!set.contains(.sysAdmin), "default capabilities must not include CAP_SYS_ADMIN")
}
}
// The default must be exactly the documented OCI baseline.
let expected = LinuxCapabilities.defaultOCICapabilities
#expect(viaProperty.capabilities.bounding == expected.bounding)
#expect(viaProperty.capabilities.effective == expected.effective)
#expect(viaProperty.capabilities.permitted == expected.permitted)
#expect(viaProperty.capabilities.inheritable == expected.inheritable)
#expect(viaProperty.capabilities.ambient == expected.ambient)
#expect(viaInit.capabilities.bounding == expected.bounding)
}
@Test func defaultMaskedAndReadonlyPathsAreOCISet() {
// Regression guard: masked/readonly paths must default to the OCI
// standard set now that capabilities default to the restricted baseline.
// Without CAP_SYS_ADMIN a workload can't unmount these, so the defaults
// are meaningful defense-in-depth shipping empty defaults would leave
// /proc/kcore and friends exposed. Cover both construction paths and
// both configuration types.
let expectedMasked = LinuxContainer.defaultMaskedPaths()
let expectedReadonly = LinuxContainer.defaultReadonlyPaths()
// Sensitive kernel paths must actually be in the defaults.
#expect(expectedMasked.contains("/proc/kcore"))
#expect(expectedMasked.contains("/sys/firmware"))
#expect(expectedReadonly.contains("/proc/sys"))
let containerViaProperty = LinuxContainer.Configuration()
let containerViaInit = LinuxContainer.Configuration(process: LinuxProcessConfiguration(arguments: ["/bin/sh"]))
let pod = LinuxPod.ContainerConfiguration()
for config in [containerViaProperty, containerViaInit] {
#expect(config.maskedPaths == expectedMasked)
#expect(config.readonlyPaths == expectedReadonly)
}
#expect(pod.maskedPaths == expectedMasked)
#expect(pod.readonlyPaths == expectedReadonly)
}
}
@@ -0,0 +1,72 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 CloudHypervisor
import Testing
@testable import Containerization
@Suite("Mount+CH")
struct MountCHTests {
@Test("block mount without options produces DiskConfig with readonly=false")
func blockNoOptions() {
let mount = Mount.block(format: "ext4", source: "/foo.img", destination: "/data")
let cfg = mount.chDiskConfig(id: "blk-0")
#expect(cfg?.path == "/foo.img")
#expect(cfg?.readonly == false)
#expect(cfg?.id == "blk-0")
#expect(cfg?.direct == nil)
#expect(cfg?.iommu == nil)
#expect(cfg?.pciSegment == nil)
}
@Test("block mount with 'ro' option produces DiskConfig with readonly=true")
func blockReadOnly() {
let mount = Mount.block(format: "ext4", source: "/foo.img", destination: "/data", options: ["ro"])
let cfg = mount.chDiskConfig(id: "blk-1")
#expect(cfg?.readonly == true)
}
@Test("non-block mount returns nil from chDiskConfig")
func chDiskConfigNilForNonBlock() {
let share = Mount.share(source: "/host", destination: "/guest")
#expect(share.chDiskConfig(id: "x") == nil)
let any = Mount.any(type: "tmpfs", source: "tmpfs", destination: "/tmp")
#expect(any.chDiskConfig(id: "x") == nil)
}
@Test("share mount produces FsConfig with tag and socket")
func shareMount() {
let mount = Mount.share(source: "/host/dir", destination: "/guest/dir")
let cfg = mount.chFsConfig(tag: "share0", socketPath: "/tmp/vfs.sock", id: "fs-0")
#expect(cfg?.tag == "share0")
#expect(cfg?.socket == "/tmp/vfs.sock")
#expect(cfg?.id == "fs-0")
#expect(cfg?.numQueues == nil)
#expect(cfg?.queueSize == nil)
#expect(cfg?.pciSegment == nil)
}
@Test("non-share mount returns nil from chFsConfig")
func chFsConfigNilForNonShare() {
let block = Mount.block(format: "ext4", source: "/foo.img", destination: "/data")
#expect(block.chFsConfig(tag: "t", socketPath: "/s", id: "x") == nil)
let any = Mount.any(type: "tmpfs", source: "tmpfs", destination: "/tmp")
#expect(any.chFsConfig(tag: "t", socketPath: "/s", id: "x") == nil)
}
}
@@ -0,0 +1,357 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationOCI
import Foundation
import Testing
@testable import Containerization
struct MountTests {
@Test func mountShareCreatesVirtiofsMount() {
let mount = Mount.share(
source: "/host/shared",
destination: "/guest/shared",
options: ["rw", "noatime"],
runtimeOptions: ["tag=shared"]
)
#expect(mount.type == "virtiofs")
#expect(mount.source == "/host/shared")
#expect(mount.destination == "/guest/shared")
#expect(mount.options == ["rw", "noatime"])
if case .virtiofs(let opts) = mount.runtimeOptions {
#expect(opts == ["tag=shared"])
} else {
#expect(Bool(false), "Expected virtiofs runtime options")
}
}
@Test func sortMountsByDestinationDepthPreventsParentShadowing() {
let mounts: [ContainerizationOCI.Mount] = [
.init(destination: "/tmp/foo/bar"),
.init(destination: "/tmp"),
.init(destination: "/var/log/app"),
.init(destination: "/var"),
]
let sorted = sortMountsByDestinationDepth(mounts)
#expect(
sorted.map(\.destination) == [
"/tmp",
"/var",
"/tmp/foo/bar",
"/var/log/app",
])
}
@Test func sortMountsByDestinationDepthPreservesOrderForEqualDepth() {
let mounts: [ContainerizationOCI.Mount] = [
.init(destination: "/b"),
.init(destination: "/a"),
.init(destination: "/c"),
]
let sorted = sortMountsByDestinationDepth(mounts)
// All same depth, order should be preserved (stable sort).
#expect(sorted.map(\.destination) == ["/b", "/a", "/c"])
}
@Test func sortMountsByDestinationDepthHandlesTrailingAndDoubleSlashes() {
let mounts: [ContainerizationOCI.Mount] = [
.init(destination: "/a//b/c"),
.init(destination: "/a/"),
]
let sorted = cleanAndSortMounts(mounts)
// Paths are cleaned: "/a/" -> "/a", "/a//b/c" -> "/a/b/c"
#expect(sorted.map(\.destination) == ["/a", "/a/b/c"])
}
@Test func sortMountsByDestinationDepthCleansDotAndDotDot() {
let mounts: [ContainerizationOCI.Mount] = [
.init(destination: "/tmp/../foo"),
.init(destination: "/tmp/./bar/baz"),
.init(destination: "/"),
]
let sorted = cleanAndSortMounts(mounts)
// "/tmp/../foo" -> "/foo", "/tmp/./bar/baz" -> "/tmp/bar/baz"
#expect(sorted.map(\.destination) == ["/", "/foo", "/tmp/bar/baz"])
}
}
@Suite("AttachedFilesystem runtimeOptions dispatch")
struct AttachedFilesystemTests {
@Test func virtioblkMountAllocatesBlockDevice() throws {
let mount = Mount.block(
format: "ext4",
source: "/path/to/disk.img",
destination: "/data"
)
let allocator = Character.blockDeviceTagAllocator()
let attached = try AttachedFilesystem(mount: mount, allocator: allocator)
#expect(attached.source == "/dev/vda")
#expect(attached.type == "ext4")
#expect(attached.destination == "/data")
}
@Test func nbdMountAllocatesBlockDevice() throws {
let mount = Mount.block(
format: "ext4",
source: "nbd://localhost:10809",
destination: "/data"
)
let allocator = Character.blockDeviceTagAllocator()
let attached = try AttachedFilesystem(mount: mount, allocator: allocator)
#expect(attached.source == "/dev/vda")
#expect(attached.type == "ext4")
#expect(attached.destination == "/data")
}
@Test func nbdMountWithNonExt4FormatAllocatesBlockDevice() throws {
let mount = Mount.block(
format: "xfs",
source: "nbd://localhost:10809",
destination: "/data"
)
let allocator = Character.blockDeviceTagAllocator()
let attached = try AttachedFilesystem(mount: mount, allocator: allocator)
#expect(attached.source == "/dev/vda")
#expect(attached.type == "xfs")
}
@Test func multipleBlockDevicesAllocateSequentially() throws {
let allocator = Character.blockDeviceTagAllocator()
let m1 = Mount.block(format: "ext4", source: "/disk1.img", destination: "/a")
let m2 = Mount.block(format: "ext4", source: "nbd://host:10809", destination: "/b")
let m3 = Mount.block(format: "ext4", source: "/disk2.img", destination: "/c")
let a1 = try AttachedFilesystem(mount: m1, allocator: allocator)
let a2 = try AttachedFilesystem(mount: m2, allocator: allocator)
let a3 = try AttachedFilesystem(mount: m3, allocator: allocator)
#expect(a1.source == "/dev/vda")
#expect(a2.source == "/dev/vdb")
#expect(a3.source == "/dev/vdc")
}
@Test func anyMountUsesSourceDirectly() throws {
let mount = Mount.any(
type: "tmpfs",
source: "tmpfs",
destination: "/tmp"
)
let allocator = Character.blockDeviceTagAllocator()
let attached = try AttachedFilesystem(mount: mount, allocator: allocator)
#expect(attached.source == "tmpfs")
}
}
@Suite("PodVolume and shared mount types")
struct PodVolumeTests {
@Test func podVolumeNBDSourceCreation() {
let volume = LinuxPod.PodVolume(
name: "shared-data",
source: .nbd(url: URL(string: "nbd://localhost:10809")!),
format: "ext4"
)
#expect(volume.name == "shared-data")
#expect(volume.format == "ext4")
if case .nbd(let url, let timeout, let readOnly) = volume.source {
#expect(url.absoluteString == "nbd://localhost:10809")
#expect(timeout == nil)
#expect(readOnly == false)
} else {
Issue.record("Expected .nbd source")
}
}
@Test func podVolumeNBDSourceWithOptions() {
let volume = LinuxPod.PodVolume(
name: "data",
source: .nbd(url: URL(string: "nbd://host:10809")!, timeout: 30, readOnly: true),
format: "xfs"
)
if case .nbd(_, let timeout, let readOnly) = volume.source {
#expect(timeout == 30)
#expect(readOnly == true)
} else {
Issue.record("Expected .nbd source")
}
}
@Test func podVolumeToMountConvertsCorrectly() {
let volume = LinuxPod.PodVolume(
name: "my-vol",
source: .nbd(url: URL(string: "nbd://host:10809/export")!),
format: "ext4"
)
let mount = volume.toMount()
#expect(mount.source == "nbd://host:10809/export")
#expect(mount.destination == "/run/volumes/my-vol")
#expect(mount.type == "ext4")
#expect(mount.isBlock)
}
@Test func podVolumeToMountWithReadOnlySetsOptions() {
let volume = LinuxPod.PodVolume(
name: "ro-vol",
source: .nbd(url: URL(string: "nbd://host:10809")!, readOnly: true),
format: "ext4"
)
let mount = volume.toMount()
#expect(mount.options.contains("ro"))
#expect(mount.isBlock)
}
@Test func podVolumeToMountWithTimeoutSetsRuntimeOption() {
let volume = LinuxPod.PodVolume(
name: "data",
source: .nbd(url: URL(string: "nbd://host:10809")!, timeout: 60),
format: "ext4"
)
let mount = volume.toMount()
if case .virtioblk(let opts) = mount.runtimeOptions {
#expect(opts.contains("vzTimeout=60.0"))
} else {
Issue.record("Expected virtioblk runtime options")
}
}
@Test func podVolumeToMountUsesMountType() {
let volume = LinuxPod.PodVolume(
name: "data",
source: .nbd(url: URL(string: "nbd://host:10809")!),
format: "xfs"
)
let mount = volume.toMount()
#expect(mount.type == "xfs")
}
@Test func podVolumeDiskImageSourceCreation() {
let volume = LinuxPod.PodVolume(
name: "disk-data",
source: .diskImage(path: URL(fileURLWithPath: "/tmp/disk.ext4")),
format: "ext4"
)
#expect(volume.name == "disk-data")
#expect(volume.format == "ext4")
if case .diskImage(let path, let readOnly) = volume.source {
#expect(path.path == "/tmp/disk.ext4")
#expect(readOnly == false)
} else {
Issue.record("Expected .diskImage source")
}
}
@Test func podVolumeDiskImageToMountConvertsCorrectly() {
let volume = LinuxPod.PodVolume(
name: "my-disk",
source: .diskImage(path: URL(fileURLWithPath: "/tmp/my-disk.ext4")),
format: "ext4"
)
let mount = volume.toMount()
// The mount source must be the raw filesystem path, not a file:// URL.
#expect(mount.source == "/tmp/my-disk.ext4")
#expect(mount.destination == "/run/volumes/my-disk")
#expect(mount.type == "ext4")
#expect(mount.isBlock)
}
@Test func podVolumeDiskImageReadOnlySetsOptions() {
let volume = LinuxPod.PodVolume(
name: "ro-disk",
source: .diskImage(path: URL(fileURLWithPath: "/tmp/ro-disk.ext4"), readOnly: true),
format: "ext4"
)
let mount = volume.toMount()
#expect(mount.options.contains("ro"))
#expect(mount.isBlock)
}
@Test func sharedMountCreation() {
let mount = Mount.sharedMount(
name: "shared-data",
destination: "/data",
options: ["ro"]
)
#expect(mount.source == "shared-data")
#expect(mount.destination == "/data")
#expect(mount.options == ["ro"])
#expect(mount.type == "none")
if case .shared = mount.runtimeOptions {
// correct
} else {
Issue.record("Expected .shared runtime options")
}
}
@Test func sharedMountDefaultOptions() {
let mount = Mount.sharedMount(name: "data", destination: "/mnt")
#expect(mount.options.isEmpty)
}
@Test func sharedMountIsNotBlock() {
let mount = Mount.sharedMount(name: "data", destination: "/mnt")
#expect(!mount.isBlock)
}
@Test func sharedMountDoesNotAllocateBlockDevice() throws {
let allocator = Character.blockDeviceTagAllocator()
// Shared mount should not consume a device letter.
let shared = Mount.sharedMount(name: "vol", destination: "/data")
let attached = try AttachedFilesystem(mount: shared, allocator: allocator)
#expect(attached.source == "vol")
// Next block device should still get vda.
let block = Mount.block(format: "ext4", source: "/disk.img", destination: "/mnt")
let blockAttached = try AttachedFilesystem(mount: block, allocator: allocator)
#expect(blockAttached.source == "/dev/vda")
}
}
@@ -0,0 +1,59 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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.
//===----------------------------------------------------------------------===//
#if os(Linux)
import Testing
@testable import Containerization
@Suite("TAP name derivation")
struct TAPNameDerivationTests {
@Test("name is deterministic for a given id")
func deterministic() {
let a = LinuxBridgedNetwork.derivedTAPName(forID: "container-abc")
let b = LinuxBridgedNetwork.derivedTAPName(forID: "container-abc")
#expect(a == b)
}
@Test("different ids produce different names")
func differentIds() {
let a = LinuxBridgedNetwork.derivedTAPName(forID: "alpha")
let b = LinuxBridgedNetwork.derivedTAPName(forID: "beta")
#expect(a != b)
}
@Test("name fits within IFNAMSIZ - 1 (15 chars)")
func ifnamsizFit() {
for id in ["a", "short", "a-much-longer-container-id-that-exceeds-typical-bounds"] {
let n = LinuxBridgedNetwork.derivedTAPName(forID: id)
#expect(n.count <= 15, "name '\(n)' exceeds IFNAMSIZ-1")
}
}
@Test("name uses czt- prefix")
func prefix() {
let n = LinuxBridgedNetwork.derivedTAPName(forID: "anything")
#expect(n.hasPrefix("czt-"))
}
@Test("hex suffix is 10 chars")
func suffixLength() {
let n = LinuxBridgedNetwork.derivedTAPName(forID: "anything")
// "czt-" is 4 chars; total 14.
#expect(n.count == 14)
}
}
#endif