Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 3 additions & 17 deletions Sources/ContainerRegistry/Blobs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,6 @@

import Foundation
import HTTPTypes
import struct Crypto.SHA256

/// Calculates the digest of a blob of data.
/// - Parameter data: Blob of data to digest.
/// - Returns: The blob's digest, in the format expected by the distribution protocol.
public func digest<D: DataProtocol>(of data: D) -> String {
// SHA256 is required; some registries might also support SHA512
let hash = SHA256.hash(data: data)
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
return "sha256:" + digest
}

extension RegistryClient {
// Internal helper method to initiate a blob upload in 'two shot' mode
Expand Down Expand Up @@ -61,9 +50,6 @@ extension RegistryClient {
}
}

// The spec says that Docker- prefix headers are no longer to be used, but also specifies that the registry digest is returned in this header.
extension HTTPField.Name { static let dockerContentDigest = Self("Docker-Content-Digest")! }

public extension RegistryClient {
func blobExists(repository: ImageReference.Repository, digest: ImageReference.Digest) async throws -> Bool {
do {
Expand Down Expand Up @@ -134,7 +120,7 @@ public extension RegistryClient {
// The server's URL is arbitrary and might already contain query items which we must not overwrite.
// The URL could even point to a different host.
let digest = digest(of: data)
let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest.utf8)")])
let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest)")])

let httpResponse = try await executeRequestThrowing(
// All blob uploads have Content-Type: application/octet-stream on the wire, even if mediatype is different
Expand All @@ -148,9 +134,9 @@ public extension RegistryClient {
// as the canonical digest for linking blobs. If the registry sends a digest we
// should check that it matches our locally-calculated digest.
if let serverDigest = httpResponse.response.headerFields[.dockerContentDigest] {
assert(digest == serverDigest)
assert("\(digest)" == serverDigest)
}
return .init(mediaType: mediaType, digest: digest, size: Int64(data.count))
return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(data.count))
}

/// Uploads a blob to the registry.
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContainerRegistry/ImageManifest+Digest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import Foundation
import struct Crypto.SHA256

public extension ImageManifest {
var digest: String {
var digest: ImageReference.Digest {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]
encoder.dateEncodingStrategy = .iso8601
Expand Down
50 changes: 34 additions & 16 deletions Sources/ContainerRegistry/ImageReference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func splitReference(_ reference: String) throws -> (String?, String) {
// Hostname heuristic: contains a '.' or a ':', or is localhost
if splits[0] != "localhost", !splits[0].contains("."), !splits[0].contains(":") { return (nil, reference) }

return (String(splits[0]), String(splits[1]))
return ("\(splits[0])", "\(splits[1])")
}

// Split the name into repository and tag parts
Expand All @@ -35,8 +35,8 @@ func parseName(_ name: String) throws -> (ImageReference.Repository, any ImageRe
let digestSplit = name.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false)
if digestSplit.count == 2 {
return (
try ImageReference.Repository(String(digestSplit[0])),
try ImageReference.Digest(String(digestSplit[1]))
try ImageReference.Repository("\(digestSplit[0])"),
try ImageReference.Digest("\(digestSplit[1])")
)
}

Expand All @@ -51,8 +51,8 @@ func parseName(_ name: String) throws -> (ImageReference.Repository, any ImageRe

// assert splits == 2
return (
try ImageReference.Repository(String(tagSplit[0])),
try ImageReference.Tag(String(tagSplit[1]))
try ImageReference.Repository("\(tagSplit[0])"),
try ImageReference.Tag("\(tagSplit[1])")
)
}

Expand Down Expand Up @@ -214,45 +214,63 @@ extension ImageReference {

/// Digest identifies a specific blob by the hash of the blob's contents.
public struct Digest: Reference, Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible {
public enum Algorithm: String, Sendable {
case sha256 = "sha256"
case sha512 = "sha512"

init(fromString rawValue: String) throws {
guard let algorithm = Algorithm(rawValue: rawValue) else {
throw RegistryClientError.invalidDigestAlgorithm(rawValue)
}
self = algorithm
}
}

var algorithm: Algorithm
var value: String

public enum ValidationError: Error, Equatable {
case emptyString
case invalidReferenceFormat(String)
case tooLong(String)
}

public init(_ rawValue: String) throws {
guard rawValue.count > 0 else {
throw ValidationError.emptyString
}

if rawValue.count > 7 + 64 {
throw ValidationError.tooLong(rawValue)
// https://github.com/opencontainers/image-spec/blob/v1.0.1/descriptor.md#sha-256
let sha256digest = /(sha256):([a-fA-F0-9]{64})/
if let match = try sha256digest.wholeMatch(in: rawValue) {
algorithm = try Algorithm(fromString: "\(match.1)")
value = "\(match.2)"
return
}

// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
let regex = /sha256:[a-fA-F0-9]{64}/
if try regex.wholeMatch(in: rawValue) == nil {
throw ValidationError.invalidReferenceFormat(rawValue)
// https://github.com/opencontainers/image-spec/blob/v1.0.1/descriptor.md#sha-512
let sha512digest = /(sha512):([a-fA-F0-9]{128})/
if let match = try sha512digest.wholeMatch(in: rawValue) {
algorithm = try Algorithm(fromString: "\(match.1)")
value = "\(match.2)"
return
}

value = rawValue
throw ValidationError.invalidReferenceFormat(rawValue)
}

public static func == (lhs: Digest, rhs: Digest) -> Bool {
lhs.value == rhs.value
lhs.algorithm == rhs.algorithm && lhs.value == rhs.value
}

public var separator: String = "@"

public var description: String {
"\(value)"
"\(algorithm):\(value)"
}

/// Printable description in a form suitable for debugging.
public var debugDescription: String {
"Digest(\(value))"
"Digest(\(algorithm):\(value))"
}
}
}
44 changes: 44 additions & 0 deletions Sources/ContainerRegistry/RegistryClient+Digest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftContainerPlugin open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Foundation
import HTTPTypes
import struct Crypto.SHA256
import struct Crypto.SHA512

// The spec says that Docker- prefix headers are no longer to be used, but also specifies that the registry digest is returned in this header.
extension HTTPField.Name { static let dockerContentDigest = Self("Docker-Content-Digest")! }

/// Calculates the digest of a blob of data.
/// - Parameters:
/// - data: Blob of data to digest.
/// - algorithm: Digest algorithm to use.
/// - Returns: The blob's digest, in the format expected by the distribution protocol.
public func digest(
of data: any DataProtocol,
algorithm: ImageReference.Digest.Algorithm = .sha256
) -> ImageReference.Digest {
// SHA256 is required; some registries might also support SHA512
switch algorithm {
case .sha256:
let hash = SHA256.hash(data: data)
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
return try! ImageReference.Digest("sha256:" + digest)

case .sha512:
let hash = SHA512.hash(data: data)
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
return try! ImageReference.Digest("sha512:" + digest)
}
}
10 changes: 8 additions & 2 deletions Sources/ContainerRegistry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,24 @@ import FoundationNetworking
import HTTPTypes
import Basics

enum RegistryClientError: Error {
public enum RegistryClientError: Error {
case registryParseError(String)
case invalidRegistryPath(String)
case invalidUploadLocation(String)
case invalidDigestAlgorithm(String)
case digestMismatch(expected: String, registry: String)
}

extension RegistryClientError: CustomStringConvertible {
var description: String {
/// Human-readable description of a RegistryClientError
public var description: String {
switch self {
case let .registryParseError(reference): return "Unable to parse registry: \(reference)"
case let .invalidRegistryPath(path): return "Unable to construct URL for registry path: \(path)"
case let .invalidUploadLocation(location): return "Received invalid upload location from registry: \(location)"
case let .invalidDigestAlgorithm(digest): return "Invalid or unsupported digest algorithm: \(digest)"
case let .digestMismatch(expected, registry):
return "Digest mismatch: expected \(expected), registry sent \(registry)"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ extension ContainerRegistry.ImageReference.Digest.ValidationError: Swift.CustomS
switch self {
case .emptyString:
return "Invalid reference format: digest cannot be empty"
case .tooLong(let rawValue):
return "Invalid reference format: digest (\(rawValue)) is too long"
case .invalidReferenceFormat(let rawValue):
return "Invalid reference format: digest (\(rawValue)) is not a valid digest"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ extension RegistryClient {
log("Layer \(digest): pushing")
let uploaded = try await destClient.putBlob(repository: destRepository, data: blob)
log("Layer \(digest): done")
assert("\(digest)" == uploaded.digest)

guard "\(digest)" == uploaded.digest else {
throw RegistryClientError.digestMismatch(expected: "\(digest)", registry: uploaded.digest)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ extension RegistryClient {
}
}

typealias DiffID = String
typealias DiffID = ImageReference.Digest
struct ImageLayer {
var descriptor: ContentDescriptor
var diffID: DiffID
Expand Down
6 changes: 3 additions & 3 deletions Sources/containertool/containertool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ extension RegistryClient {
// It is used by the runtime, which might not store the layers in
// the compressed form in which it received them from the registry.
diff_ids: baseImageConfiguration.rootfs.diff_ids
+ resourceLayers.map { $0.diffID }
+ [applicationLayer.diffID]
+ resourceLayers.map { "\($0.diffID)" }
+ ["\(applicationLayer.diffID)"]
),
history: [.init(created: timestamp, created_by: "containertool")]
)
Expand Down Expand Up @@ -375,7 +375,7 @@ extension RegistryClient {
if let tag {
reference = try ImageReference.Tag(tag)
} else {
reference = try ImageReference.Digest(manifest.digest)
reference = manifest.digest
}
let location = try await self.putManifest(
repository: destinationImage.repository,
Expand Down
68 changes: 68 additions & 0 deletions Tests/ContainerRegistryTests/ImageReferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ struct ReferenceTests {
)
),

ReferenceTestCase(
reference:
"example.com/foo@sha512:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
expected: try! ImageReference(
registry: "example.com",
repository: ImageReference.Repository("foo"),
reference: ImageReference.Digest(
"sha512:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
)
),

ReferenceTestCase(
reference: "foo:1234/bar:1234",
expected: try! ImageReference(
Expand Down Expand Up @@ -262,3 +274,59 @@ struct ReferenceTests {
)
}
}

struct DigestTests {
@Test(arguments: [
(
digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
algorithm: "sha256", value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
),
(
digest: "sha512:"
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
algorithm: "sha512",
value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
),
])
func testParseValidDigest(digest: String, algorithm: String, value: String) throws {
let parsed = try! ImageReference.Digest(digest)

#expect("\(parsed.algorithm)" == algorithm)
#expect(parsed.value == value)
#expect("\(parsed)" == digest)
}

@Test(arguments: [
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef", // short digest
"foo:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // bad algorithm
"sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // bad separator
])
func testParseInvalidDigest(digest: String) throws {
#expect(throws: ImageReference.Digest.ValidationError.invalidReferenceFormat(digest)) {
try ImageReference.Digest(digest)
}
}

@Test
func testDigestEquality() throws {
let digest1 = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
let digest2 = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
let digest3 =
"sha512:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

#expect(try ImageReference.Digest(digest1) != ImageReference.Digest(digest2))
#expect(try ImageReference.Digest(digest1) != ImageReference.Digest(digest3))

// Same string, parsed twice, should yield the same digest
let sha256left = try ImageReference.Digest(digest1)
let sha256right = try ImageReference.Digest(digest1)
#expect(sha256left == sha256right)

let sha512left = try ImageReference.Digest(digest3)
let sha512right = try ImageReference.Digest(digest3)
#expect(sha512left == sha512right)
}
}
4 changes: 2 additions & 2 deletions Tests/ContainerRegistryTests/SmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,13 @@ struct SmokeTests {

let _ = try await client.putManifest(
repository: repository,
reference: ImageReference.Digest(test_manifest.digest),
reference: test_manifest.digest,
manifest: test_manifest
)

let manifest = try await client.getManifest(
repository: repository,
reference: ImageReference.Digest(test_manifest.digest)
reference: test_manifest.digest
)
#expect(manifest.schemaVersion == 2)
#expect(manifest.config.mediaType == "application/vnd.docker.container.image.v1+json")
Expand Down