Files
J Logan 88ab3192f9 Migrate image, volume and miscellaneous system tests. (#1868)
- Part of #1833.
- Distributes fixture extensions for images and containers added as part
of previous builder PR to more sensible locations.
2026-06-30 15:25:16 -07:00

339 lines
15 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationArchive
import ContainerizationOCI
import Foundation
import Testing
@Suite
struct TestCLIImagesCommand {
private let alpine = ContainerFixture.warmupImages[0] // ghcr.io/linuxcontainers/alpine:3.20
private let alpine318 = ContainerFixture.warmupImages[1] // ghcr.io/linuxcontainers/alpine:3.18
private let busybox = ContainerFixture.warmupImages[2] // ghcr.io/containerd/busybox:1.36
/// Host architecture string for platform tests.
private var hostArchitecture: String {
#if arch(arm64)
return "arm64"
#else
return "amd64"
#endif
}
@Test func testPull() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
#expect(try f.isImagePresent(alpine), "expected \(alpine) to be present")
}
}
@Test func testPullMulti() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
try f.doPull(busybox)
#expect(try f.isImagePresent(alpine), "expected \(alpine) to be present")
#expect(try f.isImagePresent(busybox), "expected \(busybox) to be present")
}
}
@Test func testPullPlatform() async throws {
try await ContainerFixture.with { f in
let os = "linux"
let arch = "amd64"
try f.doPull(alpine, args: ["--platform", "\(os)/\(arch)"])
let output = try f.doInspectImages(alpine)
#expect(output.count == 1)
#expect(
output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
"expected variant for \(os)/\(arch) in \(output[0])")
}
}
@Test func testPullOsArch() async throws {
try await ContainerFixture.with { f in
let os = "linux"
let arch = "amd64"
try f.doPull(alpine318, args: ["--os", os, "--arch", arch])
let output = try f.doInspectImages(alpine318)
#expect(output.count == 1)
#expect(
output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
"expected variant for \(os)/\(arch)")
}
}
@Test func testPullOs() async throws {
try await ContainerFixture.with { f in
let os = "linux"
let arch = hostArchitecture
try f.doPull(alpine318, args: ["--os", os])
let output = try f.doInspectImages(alpine318)
#expect(output.count == 1)
#expect(
output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
"expected variant for \(os)/\(arch)")
}
}
@Test func testPullArch() async throws {
try await ContainerFixture.with { f in
let os = "linux"
let arch = "amd64"
try f.doPull(alpine318, args: ["--arch", arch])
let output = try f.doInspectImages(alpine318)
#expect(output.count == 1)
#expect(
output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch },
"expected variant for \(os)/\(arch)")
}
}
@Test func testPullRemoveSingle() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
#expect(try f.isImagePresent(alpine))
let tagged = "\((try Reference.parse(alpine)).name):testPullRemoveSingle"
try f.doImageTag(alpine, newName: tagged)
#expect(try f.isImagePresent(tagged))
try f.doRemoveImages([tagged])
#expect(!(try f.isImagePresent(tagged)), "expected \(tagged) to be removed")
}
}
@Test func testImageTag() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
let tagged = "\((try Reference.parse(alpine)).name):testImageTag"
try f.doImageTag(alpine, newName: tagged)
#expect(try f.isImagePresent(tagged))
}
}
@Test func testImageSaveAndLoad() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
try f.doPull(busybox)
let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoad"
let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoad"
try f.doImageTag(alpine, newName: alpineTagged)
try f.doImageTag(busybox, newName: busyboxTagged)
#expect(try f.isImagePresent(alpineTagged))
#expect(try f.isImagePresent(busyboxTagged))
let tempFile = f.testDir.appending("save-\(UUID().uuidString).tar")
try f.run(["image", "save", alpineTagged, busyboxTagged, "--output", tempFile.string]).check()
try f.doRemoveImages([alpineTagged, busyboxTagged])
#expect(!(try f.isImagePresent(alpineTagged)))
#expect(!(try f.isImagePresent(busyboxTagged)))
try f.run(["image", "load", "-i", tempFile.string]).check()
#expect(try f.isImagePresent(alpineTagged))
#expect(try f.isImagePresent(busyboxTagged))
}
}
@Test func testImageSaveToStdoutProducesCleanArchive() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
let tagged = "\((try Reference.parse(alpine)).name):testImageSaveToStdout"
try f.doImageTag(alpine, newName: tagged)
f.addCleanup { try? f.doRemoveImages([tagged]) }
let result = try f.run(["image", "save", tagged])
try result.check("save to stdout failed")
#expect(result.outputData.count >= 1024, "stdout archive too small to contain tar EOF marker")
let trailer = result.outputData.suffix(1024)
#expect(trailer.allSatisfy { $0 == 0 }, "stdout archive has trailing non-archive bytes after tar EOF marker")
#expect(result.error.contains(tagged), "expected saved image reference on stderr")
}
}
@Test func testImageSaveMissingPlatform() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
let tagged = "\((try Reference.parse(alpine)).name):testImageSaveMissingPlatform"
try f.doImageTag(alpine, newName: tagged)
f.addCleanup { try? f.doRemoveImages([tagged]) }
let tempFile = f.testDir.appending("save-missing.tar")
let result = try f.run([
"image", "save", tagged,
"--platform", "linux/arm/v5",
"--output", tempFile.string,
])
#expect(result.status != 0, "expected save to fail for missing platform")
#expect(result.error.contains("has no content for platform"))
#expect(result.error.contains("available platforms:"))
}
}
@Test func testMaxConcurrentDownloadsValidation() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["image", "pull", "--max-concurrent-downloads", "0", "alpine:latest"])
#expect(result.status != 0)
#expect(result.error.contains("maximum number of concurrent downloads must be greater than 0"))
}
}
@Test func testImageLoadRejectsInvalidMembersWithoutForce() async throws {
try await ContainerFixture.with { f in
let maliciousFilename = "pwned-\(UUID().uuidString).txt"
try f.doPull(alpine)
let tagged = "\((try Reference.parse(alpine)).name):testImageLoadRejectsInvalidMembers"
try f.doImageTag(alpine, newName: tagged)
#expect(try f.isImagePresent(tagged))
let tempFile = f.testDir.appending("save.tar")
try f.run(["image", "save", tagged, "--output", tempFile.string]).check()
try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename)
try f.doRemoveImages([tagged])
#expect(!(try f.isImagePresent(tagged)))
let loadResult = try f.run(["image", "load", "-i", tempFile.string])
#expect(loadResult.status != 0, "expected load to fail without force flag")
#expect(loadResult.error.contains("rejected paths") || loadResult.error.contains(maliciousFilename))
#expect(
!FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"),
"malicious file should not have been created")
}
}
@Test func testImageLoadAcceptsInvalidMembersWithForce() async throws {
try await ContainerFixture.with { f in
let maliciousFilename = "pwned-\(UUID().uuidString).txt"
try f.doPull(alpine)
let tagged = "\((try Reference.parse(alpine)).name):testImageLoadAcceptsInvalidMembers"
try f.doImageTag(alpine, newName: tagged)
f.addCleanup { try? f.doRemoveImages([tagged]) }
let tempFile = f.testDir.appending("save.tar")
try f.run(["image", "save", tagged, "--output", tempFile.string]).check()
try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename)
try f.doRemoveImages([tagged])
let loadResult = try f.run(["image", "load", "-i", tempFile.string, "--force"])
#expect(loadResult.status == 0, "expected load to succeed with force flag")
#expect(loadResult.error.contains("invalid members") || loadResult.error.contains(maliciousFilename))
#expect(try f.isImagePresent(tagged))
#expect(
!FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"),
"malicious file should not have been created")
}
}
@Test func testImageSaveAndLoadStdinStdout() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
try f.doPull(busybox)
let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoadStdinStdout"
let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoadStdinStdout"
try f.doImageTag(alpine, newName: alpineTagged)
try f.doImageTag(busybox, newName: busyboxTagged)
#expect(try f.isImagePresent(alpineTagged))
#expect(try f.isImagePresent(busyboxTagged))
let saveResult = try f.run(["image", "save", alpineTagged, busyboxTagged]).check()
try f.doRemoveImages([alpineTagged, busyboxTagged])
#expect(!(try f.isImagePresent(alpineTagged)))
#expect(!(try f.isImagePresent(busyboxTagged)))
try f.run(["image", "load"], stdin: saveResult.outputData).check()
#expect(try f.isImagePresent(alpineTagged))
#expect(try f.isImagePresent(busyboxTagged))
}
}
@Test func testImageVariantSizeFieldExists() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
let result = try f.run(["image", "ls", "--format", "json"]).check()
guard let json = try JSONSerialization.jsonObject(with: result.outputData) as? [[String: Any]],
let image = json.first
else {
Issue.record("failed to parse image list JSON or no images found")
return
}
let variants = image["variants"] as? [[String: Any]] ?? []
#expect(!variants.isEmpty, "expected at least one variant")
#expect(
variants.contains { ($0["size"] as? Int ?? 0) > 0 },
"expected at least one variant with non-zero size")
}
}
@Test func testImageListTableFormat() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
let result = try f.run(["image", "ls"]).check()
#expect(["NAME", "TAG", "DIGEST"].allSatisfy { result.output.contains($0) })
#expect(result.output.contains("alpine"))
}
}
@Test func testInspectMissingImageFails() async throws {
try await ContainerFixture.with { f in
let result = try f.run(["image", "inspect", "definitely-missing-image:latest"])
#expect(result.status != 0)
#expect(result.error.contains("image not found"))
}
}
@Test func testImageLoadMissingFileErrorToStderr() async throws {
try await ContainerFixture.with { f in
let missingPath = "/path/that/does/not/exist-\(UUID().uuidString)"
let result = try f.run(["image", "load", "-i", missingPath])
#expect(result.status != 0)
#expect(result.output.isEmpty, "stdout should be empty")
#expect(result.error.contains("file does not exist") && result.error.contains(missingPath))
}
}
// MARK: - Private helpers
private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws {
let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)"
let evilEntryContent = "pwned\n".data(using: .utf8)!
let tempModifiedTar = URL(filePath: tarPath + ".modified")
let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar)
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)
}
}
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()
try FileManager.default.removeItem(atPath: tarPath)
try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath))
}
}