From e6a5acdff217610cbf1d91663cbd573a3fd033d9 Mon Sep 17 00:00:00 2001 From: Raj Date: Mon, 23 Feb 2026 12:09:16 -0800 Subject: [PATCH] Add OCI 1.1 subject, artifactType, and referrers API (#546) This PR adds OCI Image Spec v1.1 artifact support. It extends Manifest, Index, and Descriptor with the `subject` and `artifactType` fields, adds a `referrers()` method to RegistryClient implementing the OCI Distribution Spec v1.1 referrers API. Also, I've added some unit unit tests for backward compatibility and roundtrip encpding of all new fields --- .../Client/RegistryClient+Referrers.swift | 58 +++++++++++ Sources/ContainerizationOCI/Descriptor.swift | 8 +- Sources/ContainerizationOCI/Index.swift | 12 ++- Sources/ContainerizationOCI/Manifest.swift | 10 +- .../OCIImageTests.swift | 99 +++++++++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 Sources/ContainerizationOCI/Client/RegistryClient+Referrers.swift diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Referrers.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Referrers.swift new file mode 100644 index 00000000..14ca2be2 --- /dev/null +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Referrers.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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 AsyncHTTPClient +import ContainerizationError +import Foundation +import NIOFoundationCompat + +extension RegistryClient { + /// Query the OCI referrers API for artifacts that reference a given manifest digest. + /// + /// Implements `GET /v2/{name}/referrers/{digest}` from the OCI Distribution Spec v1.1. + /// + /// - Parameters: + /// - name: The repository name (e.g., "library/ubuntu"). + /// - digest: The digest of the subject manifest (e.g., "sha256:abc123..."). + /// - artifactType: Optional filter to return only referrers with a matching artifactType. + /// - Returns: An `Index` whose `manifests` array contains descriptors of referring artifacts. + /// Returns an empty index if the registry does not support the referrers API. + public func referrers(name: String, digest: String, artifactType: String? = nil) async throws -> Index { + var components = base + components.path = "/v2/\(name)/referrers/\(digest)" + + if let artifactType { + components.queryItems = [URLQueryItem(name: "artifactType", value: artifactType)] + } + + let headers = [("Accept", MediaTypes.index)] + + return try await request(components: components, method: .GET, headers: headers) { response in + if response.status == .notFound { + return Index(schemaVersion: 2, manifests: []) + } + + guard response.status == .ok else { + let url = components.url?.absoluteString ?? "unknown" + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) + } + + let buffer = try await response.body.collect(upTo: self.bufferSize) + return try JSONDecoder().decode(Index.self, from: buffer) + } + } +} diff --git a/Sources/ContainerizationOCI/Descriptor.swift b/Sources/ContainerizationOCI/Descriptor.swift index 6cbaa9f3..4606ade6 100644 --- a/Sources/ContainerizationOCI/Descriptor.swift +++ b/Sources/ContainerizationOCI/Descriptor.swift @@ -42,9 +42,14 @@ public struct Descriptor: Codable, Sendable, Equatable { /// This should only be used when referring to a manifest. public var platform: Platform? + /// artifactType specifies the IANA media type of the artifact. + /// + /// Used in referrers API responses to indicate the type of each referring artifact. + public let artifactType: String? + public init( mediaType: String, digest: String, size: Int64, urls: [String]? = nil, annotations: [String: String]? = nil, - platform: Platform? = nil + platform: Platform? = nil, artifactType: String? = nil ) { self.mediaType = mediaType self.digest = digest @@ -52,5 +57,6 @@ public struct Descriptor: Codable, Sendable, Equatable { self.urls = urls self.annotations = annotations self.platform = platform + self.artifactType = artifactType } } diff --git a/Sources/ContainerizationOCI/Index.swift b/Sources/ContainerizationOCI/Index.swift index 792e3cb5..26f7c47e 100644 --- a/Sources/ContainerizationOCI/Index.swift +++ b/Sources/ContainerizationOCI/Index.swift @@ -34,14 +34,22 @@ public struct Index: Codable, Sendable { /// annotations contains arbitrary metadata for the image index. public var annotations: [String: String]? + /// `subject` references another manifest this index is an artifact of. + public let subject: Descriptor? + + /// `artifactType` specifies the IANA media type of the artifact this index represents. + public let artifactType: String? + public init( schemaVersion: Int = 2, mediaType: String = MediaTypes.index, manifests: [Descriptor], - annotations: [String: String]? = nil + annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil ) { self.schemaVersion = schemaVersion self.mediaType = mediaType self.manifests = manifests self.annotations = annotations + self.subject = subject + self.artifactType = artifactType } public init(from decoder: Decoder) throws { @@ -50,5 +58,7 @@ public struct Index: Codable, Sendable { self.mediaType = try container.decodeIfPresent(String.self, forKey: .mediaType) ?? "" self.manifests = try container.decode([Descriptor].self, forKey: .manifests) self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations) + self.subject = try container.decodeIfPresent(Descriptor.self, forKey: .subject) + self.artifactType = try container.decodeIfPresent(String.self, forKey: .artifactType) } } diff --git a/Sources/ContainerizationOCI/Manifest.swift b/Sources/ContainerizationOCI/Manifest.swift index a4e0e506..ad3560e0 100644 --- a/Sources/ContainerizationOCI/Manifest.swift +++ b/Sources/ContainerizationOCI/Manifest.swift @@ -36,14 +36,22 @@ public struct Manifest: Codable, Sendable { /// `annotations` contains arbitrary metadata for the image manifest. public let annotations: [String: String]? + /// `subject` references another manifest this manifest is an artifact of. + public let subject: Descriptor? + + /// `artifactType` specifies the IANA media type of the artifact this manifest represents. + public let artifactType: String? + public init( schemaVersion: Int = 2, mediaType: String = MediaTypes.imageManifest, config: Descriptor, layers: [Descriptor], - annotations: [String: String]? = nil + annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil ) { self.schemaVersion = schemaVersion self.mediaType = mediaType self.config = config self.layers = layers self.annotations = annotations + self.subject = subject + self.artifactType = artifactType } } diff --git a/Tests/ContainerizationOCITests/OCIImageTests.swift b/Tests/ContainerizationOCITests/OCIImageTests.swift index 9426394f..e8d7fb62 100644 --- a/Tests/ContainerizationOCITests/OCIImageTests.swift +++ b/Tests/ContainerizationOCITests/OCIImageTests.swift @@ -16,6 +16,7 @@ // +import Foundation import Testing @testable import ContainerizationOCI @@ -36,6 +37,30 @@ struct OCITests { #expect(descriptor.platform?.architecture == "arm64") #expect(descriptor.platform?.os == "linux") + #expect(descriptor.artifactType == nil) + } + + @Test func descriptorWithArtifactType() throws { + let testArtifactType = "application/vnd.example.test.v1+json" + let descriptor = ContainerizationOCI.Descriptor( + mediaType: MediaTypes.imageManifest, + digest: "sha256:abc123", + size: 1234, + artifactType: testArtifactType + ) + #expect(descriptor.artifactType == testArtifactType) + + let data = try JSONEncoder().encode(descriptor) + let decoded = try JSONDecoder().decode(ContainerizationOCI.Descriptor.self, from: data) + #expect(decoded.artifactType == testArtifactType) + } + + @Test func descriptorWithoutArtifactTypeDecodesAsNil() throws { + let json = """ + {"mediaType":"application/vnd.oci.descriptor.v1+json","digest":"sha256:abc","size":0} + """ + let decoded = try JSONDecoder().decode(ContainerizationOCI.Descriptor.self, from: json.data(using: .utf8)!) + #expect(decoded.artifactType == nil) } @Test func index() { @@ -47,6 +72,37 @@ struct OCITests { let index = ContainerizationOCI.Index(schemaVersion: 1, manifests: descriptors) #expect(index.manifests.count == 5) + #expect(index.subject == nil) + #expect(index.artifactType == nil) + } + + @Test func indexWithSubjectAndArtifactType() throws { + let testArtifactType = "application/vnd.example.test.v1+json" + let subject = ContainerizationOCI.Descriptor(mediaType: MediaTypes.imageManifest, digest: "sha256:subject", size: 512) + let index = ContainerizationOCI.Index( + schemaVersion: 2, + manifests: [], + subject: subject, + artifactType: testArtifactType + ) + #expect(index.subject?.digest == "sha256:subject") + #expect(index.artifactType == testArtifactType) + + let data = try JSONEncoder().encode(index) + let decoded = try JSONDecoder().decode(ContainerizationOCI.Index.self, from: data) + #expect(decoded.subject?.digest == "sha256:subject") + #expect(decoded.artifactType == testArtifactType) + } + + @Test func indexDecodesWithoutNewFields() throws { + let json = """ + {"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.descriptor.v1+json","digest":"sha256:abc","size":10}]} + """ + let decoded = try JSONDecoder().decode(ContainerizationOCI.Index.self, from: json.data(using: .utf8)!) + #expect(decoded.schemaVersion == 2) + #expect(decoded.manifests.count == 1) + #expect(decoded.subject == nil) + #expect(decoded.artifactType == nil) } @Test func manifests() { @@ -61,5 +117,48 @@ struct OCITests { let manifest = ContainerizationOCI.Manifest(schemaVersion: 1, config: config, layers: descriptors) #expect(manifest.config.digest == "123") #expect(manifest.layers.count == 5) + #expect(manifest.subject == nil) + #expect(manifest.artifactType == nil) + } + + @Test func manifestWithSubjectAndArtifactType() throws { + let testArtifactType = "application/vnd.example.test.v1+json" + let config = ContainerizationOCI.Descriptor(mediaType: MediaTypes.emptyJSON, digest: "sha256:empty", size: 2) + let subject = ContainerizationOCI.Descriptor(mediaType: MediaTypes.imageManifest, digest: "sha256:target", size: 1234) + let layer = ContainerizationOCI.Descriptor( + mediaType: testArtifactType, + digest: "sha256:meta", + size: 89, + annotations: ["org.opencontainers.image.title": "metadata.json"] + ) + + let manifest = ContainerizationOCI.Manifest( + config: config, + layers: [layer], + subject: subject, + artifactType: testArtifactType + ) + #expect(manifest.subject?.digest == "sha256:target") + #expect(manifest.artifactType == testArtifactType) + #expect(manifest.layers[0].annotations?["org.opencontainers.image.title"] == "metadata.json") + + let data = try JSONEncoder().encode(manifest) + let decoded = try JSONDecoder().decode(ContainerizationOCI.Manifest.self, from: data) + #expect(decoded.subject?.digest == "sha256:target") + #expect(decoded.artifactType == testArtifactType) + } + + @Test func manifestDecodesWithoutNewFields() throws { + let json = """ + { + "schemaVersion": 2, + "config": {"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:abc","size":2}, + "layers": [] + } + """ + let decoded = try JSONDecoder().decode(ContainerizationOCI.Manifest.self, from: json.data(using: .utf8)!) + #expect(decoded.schemaVersion == 2) + #expect(decoded.subject == nil) + #expect(decoded.artifactType == nil) } }