diff --git a/Package.swift b/Package.swift index f4cad9e06..ccc2ab18d 100644 --- a/Package.swift +++ b/Package.swift @@ -252,6 +252,12 @@ let package = Package( ], path: "Sources/Services/ContainerImagesService/Server" ), + .testTarget( + name: "ImagesServiceTests", + dependencies: [ + "ContainerImagesService" + ] + ), .target( name: "ContainerImagesServiceClient", dependencies: [ diff --git a/Sources/Services/ContainerImagesService/Server/DockerArchiveConverter.swift b/Sources/Services/ContainerImagesService/Server/DockerArchiveConverter.swift new file mode 100644 index 000000000..acda751e4 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/DockerArchiveConverter.swift @@ -0,0 +1,226 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import CryptoKit +import Foundation + +struct DockerArchiveConverter { + static let ociLayoutFile = "oci-layout" + static let dockerManifestFile = "manifest.json" + + private static let ociLayoutMediaType = "application/vnd.oci.image.index.v1+json" + private static let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json" + private static let ociConfigMediaType = "application/vnd.oci.image.config.v1+json" + private static let ociLayerMediaType = "application/vnd.oci.image.layer.v1.tar" + private static let ociGzipLayerMediaType = "application/vnd.oci.image.layer.v1.tar+gzip" + private static let refNameAnnotation = "org.opencontainers.image.ref.name" + + static func convertIfNeeded(at directory: URL) throws { + let ociLayoutPath = directory.appendingPathComponent(Self.ociLayoutFile).path + if FileManager.default.fileExists(atPath: ociLayoutPath) { + return + } + + let dockerManifestURL = directory.appendingPathComponent(Self.dockerManifestFile) + if FileManager.default.fileExists(atPath: dockerManifestURL.path) { + try Self.convert(at: directory, dockerManifestURL: dockerManifestURL) + } + } + + private static func convert(at directory: URL, dockerManifestURL: URL) throws { + let decoder = JSONDecoder() + let archiveManifest = try decoder.decode([DockerManifestEntry].self, from: Data(contentsOf: dockerManifestURL)) + let blobsDirectory = directory.appendingPathComponent("blobs", isDirectory: true).appendingPathComponent("sha256", isDirectory: true) + try FileManager.default.createDirectory(at: blobsDirectory, withIntermediateDirectories: true) + + var indexDescriptors: [Descriptor] = [] + for entry in archiveManifest { + let configURL = try Self.archiveMemberURL(entry.config, relativeTo: directory) + let configBlob = try Self.copyBlob(from: configURL, to: blobsDirectory) + let configDescriptor = Descriptor(mediaType: Self.ociConfigMediaType, digest: configBlob.digest, size: configBlob.size) + + var layerDescriptors: [Descriptor] = [] + for layer in entry.layers { + let layerURL = try Self.archiveMemberURL(layer, relativeTo: directory) + let layerBlob = try Self.copyBlob(from: layerURL, to: blobsDirectory) + layerDescriptors.append(Descriptor(mediaType: try Self.layerMediaType(for: layerURL), digest: layerBlob.digest, size: layerBlob.size)) + } + + let imageManifest = ImageManifest(schemaVersion: 2, config: configDescriptor, layers: layerDescriptors) + let manifestData = try Self.encode(imageManifest) + let manifestDigest = Self.digest(manifestData) + try manifestData.write(to: blobsDirectory.appendingPathComponent(manifestDigest.digestValue), options: .atomic) + + let imageConfig = try? decoder.decode(DockerImageConfig.self, from: Data(contentsOf: configURL)) + let tags = entry.repoTags.filter { !$0.isEmpty && $0 != ":" } + if tags.isEmpty { + indexDescriptors.append(Descriptor(mediaType: Self.ociManifestMediaType, digest: manifestDigest.digest, size: manifestDigest.size, platform: imageConfig?.platform)) + } else { + for tag in tags { + indexDescriptors.append( + Descriptor( + mediaType: Self.ociManifestMediaType, + digest: manifestDigest.digest, + size: manifestDigest.size, + annotations: [Self.refNameAnnotation: tag], + platform: imageConfig?.platform + )) + } + } + } + + let index = ImageIndex(schemaVersion: 2, mediaType: Self.ociLayoutMediaType, manifests: indexDescriptors) + try Self.encode(index).write(to: directory.appendingPathComponent("index.json"), options: .atomic) + try Self.encode(ImageLayout(imageLayoutVersion: "1.0.0")).write(to: directory.appendingPathComponent(Self.ociLayoutFile), options: .atomic) + } + + private static func archiveMemberURL(_ member: String, relativeTo directory: URL) throws -> URL { + guard !member.hasPrefix("/") && !member.split(separator: "/").contains("..") else { + throw ContainerizationError(.invalidArgument, message: "docker archive member escapes image directory: \(member)") + } + return URL(fileURLWithPath: member, relativeTo: directory).standardizedFileURL + } + + private static func copyBlob(from source: URL, to blobsDirectory: URL) throws -> Blob { + let blob = try Self.digest(source) + let destination = blobsDirectory.appendingPathComponent(blob.digestValue) + if !FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.copyItem(at: source, to: destination) + } + return blob + } + + private static func layerMediaType(for url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { + try? handle.close() + } + let header = handle.readData(ofLength: 2) + if header.count == 2 && header[header.startIndex] == 0x1f && header[header.index(after: header.startIndex)] == 0x8b { + return Self.ociGzipLayerMediaType + } + return Self.ociLayerMediaType + } + + private static func digest(_ url: URL) throws -> Blob { + let handle = try FileHandle(forReadingFrom: url) + defer { + try? handle.close() + } + + var hasher = SHA256() + var size = 0 + while true { + let data = handle.readData(ofLength: 1024 * 1024) + if data.isEmpty { + break + } + size += data.count + hasher.update(data: data) + } + + let digestValue = hasher.finalize().map { String(format: "%02x", $0) }.joined() + return Blob(digest: "sha256:\(digestValue)", digestValue: digestValue, size: size) + } + + private static func digest(_ data: Data) -> Blob { + let digestValue = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return Blob(digest: "sha256:\(digestValue)", digestValue: digestValue, size: data.count) + } + + private static func encode(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + } +} + +private struct DockerManifestEntry: Decodable { + let config: String + let repoTags: [String] + let layers: [String] + + enum CodingKeys: String, CodingKey { + case config = "Config" + case repoTags = "RepoTags" + case layers = "Layers" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.config = try container.decode(String.self, forKey: .config) + self.repoTags = try container.decodeIfPresent([String].self, forKey: .repoTags) ?? [] + self.layers = try container.decode([String].self, forKey: .layers) + } +} + +private struct DockerImageConfig: Decodable { + let architecture: String? + let os: String? + let variant: String? + + var platform: Platform? { + guard let architecture, let os else { + return nil + } + return Platform(architecture: architecture, os: os, variant: variant) + } +} + +private struct Blob { + let digest: String + let digestValue: String + let size: Int +} + +private struct ImageLayout: Encodable { + let imageLayoutVersion: String +} + +private struct ImageIndex: Encodable { + let schemaVersion: Int + let mediaType: String + let manifests: [Descriptor] +} + +private struct ImageManifest: Encodable { + let schemaVersion: Int + let config: Descriptor + let layers: [Descriptor] +} + +private struct Descriptor: Encodable { + let mediaType: String + let digest: String + let size: Int + let annotations: [String: String]? + let platform: Platform? + + init(mediaType: String, digest: String, size: Int, annotations: [String: String]? = nil, platform: Platform? = nil) { + self.mediaType = mediaType + self.digest = digest + self.size = size + self.annotations = annotations + self.platform = platform + } +} + +private struct Platform: Encodable { + let architecture: String + let os: String + let variant: String? +} diff --git a/Sources/Services/ContainerImagesService/Server/ImagesService.swift b/Sources/Services/ContainerImagesService/Server/ImagesService.swift index 2fb06ed1c..3379d9882 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesService.swift @@ -238,6 +238,7 @@ public actor ImagesService { throw ContainerizationError(.invalidArgument, message: "cannot load tar image with rejected paths: \(rejectedMembers)") } + try DockerArchiveConverter.convertIfNeeded(at: tempDir) let loaded = try await self.imageStore.load(from: tempDir) var images: [ImageDescription] = [] for image in loaded { diff --git a/Tests/ImagesServiceTests/DockerArchiveConverterTests.swift b/Tests/ImagesServiceTests/DockerArchiveConverterTests.swift new file mode 100644 index 000000000..92886eb5c --- /dev/null +++ b/Tests/ImagesServiceTests/DockerArchiveConverterTests.swift @@ -0,0 +1,188 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerImagesService + +struct DockerArchiveConverterTests { + @Test func convertsDockerArchiveToOciLayout() throws { + let directory = try Self.makeDockerArchive(repoTags: ["example.com/test/image:latest"]) + defer { + try? FileManager.default.removeItem(at: directory) + } + + try DockerArchiveConverter.convertIfNeeded(at: directory) + + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("oci-layout").path)) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("index.json").path)) + + let index = try Self.readJSON(Index.self, from: directory.appendingPathComponent("index.json")) + #expect(index.schemaVersion == 2) + #expect(index.mediaType == "application/vnd.oci.image.index.v1+json") + #expect(index.manifests.count == 1) + #expect(index.manifests[0].mediaType == "application/vnd.oci.image.manifest.v1+json") + #expect(index.manifests[0].annotations?["org.opencontainers.image.ref.name"] == "example.com/test/image:latest") + #expect(index.manifests[0].platform?.os == "linux") + #expect(index.manifests[0].platform?.architecture == "arm64") + + let manifest = try Self.readBlob(Manifest.self, digest: index.manifests[0].digest, from: directory) + #expect(manifest.schemaVersion == 2) + #expect(manifest.config.mediaType == "application/vnd.oci.image.config.v1+json") + #expect(manifest.layers.count == 1) + #expect(manifest.layers[0].mediaType == "application/vnd.oci.image.layer.v1.tar") + #expect(FileManager.default.fileExists(atPath: Self.blobPath(manifest.config.digest, in: directory).path)) + #expect(FileManager.default.fileExists(atPath: Self.blobPath(manifest.layers[0].digest, in: directory).path)) + } + + @Test func createsOneIndexDescriptorPerRepoTag() throws { + let directory = try Self.makeDockerArchive(repoTags: ["example.com/test/image:one", "example.com/test/image:two"]) + defer { + try? FileManager.default.removeItem(at: directory) + } + + try DockerArchiveConverter.convertIfNeeded(at: directory) + + let index = try Self.readJSON(Index.self, from: directory.appendingPathComponent("index.json")) + #expect(index.manifests.count == 2) + #expect(index.manifests[0].digest == index.manifests[1].digest) + #expect(Set(index.manifests.compactMap { $0.annotations?["org.opencontainers.image.ref.name"] }) == ["example.com/test/image:one", "example.com/test/image:two"]) + } + + @Test func rejectsDockerArchiveConfigPathEscapingImageDirectory() throws { + let directory = try Self.makeDockerArchive(repoTags: ["example.com/test/image:latest"], config: "../config.json") + defer { + try? FileManager.default.removeItem(at: directory) + } + + #expect { + try DockerArchiveConverter.convertIfNeeded(at: directory) + } throws: { error in + String(describing: error).contains("docker archive member escapes image directory: ../config.json") + } + } + + @Test func rejectsDockerArchiveLayerPathEscapingImageDirectory() throws { + let directory = try Self.makeDockerArchive(repoTags: ["example.com/test/image:latest"], layers: ["/tmp/layer.tar"]) + defer { + try? FileManager.default.removeItem(at: directory) + } + + #expect { + try DockerArchiveConverter.convertIfNeeded(at: directory) + } throws: { error in + String(describing: error).contains("docker archive member escapes image directory: /tmp/layer.tar") + } + } + + @Test func leavesExistingOciLayoutUntouched() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + let existingLayout = Data(#"{"imageLayoutVersion":"1.0.0","extra":"kept"}"#.utf8) + try existingLayout.write(to: directory.appendingPathComponent("oci-layout")) + try Data("not docker".utf8).write(to: directory.appendingPathComponent("manifest.json")) + + try DockerArchiveConverter.convertIfNeeded(at: directory) + + #expect(try Data(contentsOf: directory.appendingPathComponent("oci-layout")) == existingLayout) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("index.json").path)) + } + + @Test func ignoresDirectoriesWithoutRecognizedLayout() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + try DockerArchiveConverter.convertIfNeeded(at: directory) + + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("oci-layout").path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("index.json").path)) + } + + private static func makeDockerArchive(repoTags: [String], config configPath: String = "config.json", layers: [String] = ["layer/layer.tar"]) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let config = Data(#"{"architecture":"arm64","config":{},"os":"linux","rootfs":{"diff_ids":[],"type":"layers"}}"#.utf8) + try config.write(to: directory.appendingPathComponent("config.json")) + + let layerDirectory = directory.appendingPathComponent("layer", isDirectory: true) + try FileManager.default.createDirectory(at: layerDirectory, withIntermediateDirectories: true) + try Data("layer contents".utf8).write(to: layerDirectory.appendingPathComponent("layer.tar")) + + let manifest = DockerManifestFixture(config: configPath, repoTags: repoTags, layers: layers) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + try encoder.encode([manifest]).write(to: directory.appendingPathComponent("manifest.json")) + return directory + } + + private static func readJSON(_ type: T.Type, from url: URL) throws -> T { + try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } + + private static func readBlob(_ type: T.Type, digest: String, from directory: URL) throws -> T { + try Self.readJSON(type, from: Self.blobPath(digest, in: directory)) + } + + private static func blobPath(_ digest: String, in directory: URL) -> URL { + let digestValue = digest.replacingOccurrences(of: "sha256:", with: "") + return directory.appendingPathComponent("blobs/sha256/\(digestValue)") + } +} + +private struct DockerManifestFixture: Encodable { + let config: String + let repoTags: [String] + let layers: [String] + + enum CodingKeys: String, CodingKey { + case config = "Config" + case repoTags = "RepoTags" + case layers = "Layers" + } +} + +private struct Index: Decodable { + let schemaVersion: Int + let mediaType: String + let manifests: [Descriptor] +} + +private struct Manifest: Decodable { + let schemaVersion: Int + let config: Descriptor + let layers: [Descriptor] +} + +private struct Descriptor: Decodable { + let mediaType: String + let digest: String + let size: Int + let annotations: [String: String]? + let platform: Platform? +} + +private struct Platform: Decodable { + let architecture: String + let os: String +}