Files
container/Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift
T
J Logan 3e93416b9a Merge commit from fork
* Admit only valid OCI layout files when loading image.

- Adds `AdmissionMapper` protocol for validating archive
  member paths and normalizing them to relative paths
  under the extraction root directory.
- Renames `Reader.swift` to `ArchiveReader.swift` to
  match type name.
- Rework `TempDir` to address `NSString.utf8String`
  deprecation warning in Swift 6.2.3.
- Rework `ArchiveReader.extractContent()` to use an
  `AdmissionMapper` to validate and remap archive
   members before extracting.
- Adds `IdentityAdmissionWrapper` for naive extraction.
- Adds `NoSymlinkAdmissionWrapper` that only extracts
  regular files and directories under the extraction root.
- Adds `OCIImageAdmissionWrapper` that only extracts
  valid OCI image layout paths.
- Use `OCIImageAdmissionWrapper` for `cctl image load`
  and print rejected paths.

* PR feedback.

* Adds public init() for TrustedAdmissionMapper.

* Replace AdmissionMapper with more secure extraction.

- Adds FileDescriptor.mkdirSecure() to prevent
  root escapes on member pathnames, and to prevent
  symlink traversal.
- Adds FileDescriptor.unlinkRecursive() to
  facilitate overwrites when there are multiple
  archive entries with the same member path.
- Adds FileDescriptor.validateSymlinkTargetInRoot()
  to validate that extracted symlink targets do
  not escape the root.
- Rewrite ArchiveReader.extractContents() to use
  secure path functions.

* Remove unneeded symlink check, rename files.

* Simplify the lexical normalizer workaround.

* Reject member paths containing parent traversal components.

* Remove unused lexical normalization workaround.

* Fix leaking fds, extract absolute members as relative.
2026-01-15 12:52:02 -06:00

115 lines
4.6 KiB
Swift

//===----------------------------------------------------------------------===//
// 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 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)
}
}