mirror of
https://github.com/apple/container.git
synced 2026-07-12 20:47:04 +00:00
88ab3192f9
- Part of #1833. - Distributes fixture extensions for images and containers added as part of previous builder PR to more sensible locations.
339 lines
15 KiB
Swift
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))
|
|
}
|
|
}
|