Skip to content

Commit 792ca55

Browse files
authored
ContainerRegistry: Parse digest algorithm and value into separate fields (#144)
Motivation ---------- SHA256 is the most common digest algorithm for blobs, but SHA512 can also be used and the [image specification](https://github.com/opencontainers/image-spec/blob/v1.0.1/descriptor.md#digests) provides for other options. This change lays the foundations for generating and validating hashes other than SHA256, and reduces the use of untyped strings to represent digests. Modifications ------------- * Parse the digest into separate algorithm and value fields, instead of just validating * Add support for parsing and generating SHA512 digests * Throw a meaningful error instead of asserting when the registry returns an unexpected digest for an uploaded blob Result ------ SHA512 digests can be parsed and verified Test Plan --------- Existing tests continue to pass. New parser tests added for SHA512 digests.
1 parent 63e03a5 commit 792ca55

File tree

11 files changed

+168
-45
lines changed

11 files changed

+168
-45
lines changed

Sources/ContainerRegistry/Blobs.swift

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,6 @@
1414

1515
import Foundation
1616
import HTTPTypes
17-
import struct Crypto.SHA256
18-
19-
/// Calculates the digest of a blob of data.
20-
/// - Parameter data: Blob of data to digest.
21-
/// - Returns: The blob's digest, in the format expected by the distribution protocol.
22-
public func digest<D: DataProtocol>(of data: D) -> String {
23-
// SHA256 is required; some registries might also support SHA512
24-
let hash = SHA256.hash(data: data)
25-
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
26-
return "sha256:" + digest
27-
}
2817

2918
extension RegistryClient {
3019
// Internal helper method to initiate a blob upload in 'two shot' mode
@@ -61,9 +50,6 @@ extension RegistryClient {
6150
}
6251
}
6352

64-
// 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.
65-
extension HTTPField.Name { static let dockerContentDigest = Self("Docker-Content-Digest")! }
66-
6753
public extension RegistryClient {
6854
func blobExists(repository: ImageReference.Repository, digest: ImageReference.Digest) async throws -> Bool {
6955
do {
@@ -134,7 +120,7 @@ public extension RegistryClient {
134120
// The server's URL is arbitrary and might already contain query items which we must not overwrite.
135121
// The URL could even point to a different host.
136122
let digest = digest(of: data)
137-
let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest.utf8)")])
123+
let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest)")])
138124

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

156142
/// Uploads a blob to the registry.

Sources/ContainerRegistry/ImageManifest+Digest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import Foundation
1616
import struct Crypto.SHA256
1717

1818
public extension ImageManifest {
19-
var digest: String {
19+
var digest: ImageReference.Digest {
2020
let encoder = JSONEncoder()
2121
encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]
2222
encoder.dateEncodingStrategy = .iso8601

Sources/ContainerRegistry/ImageReference.swift

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func splitReference(_ reference: String) throws -> (String?, String) {
2424
// Hostname heuristic: contains a '.' or a ':', or is localhost
2525
if splits[0] != "localhost", !splits[0].contains("."), !splits[0].contains(":") { return (nil, reference) }
2626

27-
return (String(splits[0]), String(splits[1]))
27+
return ("\(splits[0])", "\(splits[1])")
2828
}
2929

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

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

5252
// assert splits == 2
5353
return (
54-
try ImageReference.Repository(String(tagSplit[0])),
55-
try ImageReference.Tag(String(tagSplit[1]))
54+
try ImageReference.Repository("\(tagSplit[0])"),
55+
try ImageReference.Tag("\(tagSplit[1])")
5656
)
5757
}
5858

@@ -214,45 +214,63 @@ extension ImageReference {
214214

215215
/// Digest identifies a specific blob by the hash of the blob's contents.
216216
public struct Digest: Reference, Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible {
217+
public enum Algorithm: String, Sendable {
218+
case sha256 = "sha256"
219+
case sha512 = "sha512"
220+
221+
init(fromString rawValue: String) throws {
222+
guard let algorithm = Algorithm(rawValue: rawValue) else {
223+
throw RegistryClientError.invalidDigestAlgorithm(rawValue)
224+
}
225+
self = algorithm
226+
}
227+
}
228+
229+
var algorithm: Algorithm
217230
var value: String
218231

219232
public enum ValidationError: Error, Equatable {
220233
case emptyString
221234
case invalidReferenceFormat(String)
222-
case tooLong(String)
223235
}
224236

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

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

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

240-
value = rawValue
258+
throw ValidationError.invalidReferenceFormat(rawValue)
241259
}
242260

243261
public static func == (lhs: Digest, rhs: Digest) -> Bool {
244-
lhs.value == rhs.value
262+
lhs.algorithm == rhs.algorithm && lhs.value == rhs.value
245263
}
246264

247265
public var separator: String = "@"
248266

249267
public var description: String {
250-
"\(value)"
268+
"\(algorithm):\(value)"
251269
}
252270

253271
/// Printable description in a form suitable for debugging.
254272
public var debugDescription: String {
255-
"Digest(\(value))"
273+
"Digest(\(algorithm):\(value))"
256274
}
257275
}
258276
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftContainerPlugin open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
import HTTPTypes
17+
import struct Crypto.SHA256
18+
import struct Crypto.SHA512
19+
20+
// 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.
21+
extension HTTPField.Name { static let dockerContentDigest = Self("Docker-Content-Digest")! }
22+
23+
/// Calculates the digest of a blob of data.
24+
/// - Parameters:
25+
/// - data: Blob of data to digest.
26+
/// - algorithm: Digest algorithm to use.
27+
/// - Returns: The blob's digest, in the format expected by the distribution protocol.
28+
public func digest(
29+
of data: any DataProtocol,
30+
algorithm: ImageReference.Digest.Algorithm = .sha256
31+
) -> ImageReference.Digest {
32+
// SHA256 is required; some registries might also support SHA512
33+
switch algorithm {
34+
case .sha256:
35+
let hash = SHA256.hash(data: data)
36+
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
37+
return try! ImageReference.Digest("sha256:" + digest)
38+
39+
case .sha512:
40+
let hash = SHA512.hash(data: data)
41+
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
42+
return try! ImageReference.Digest("sha512:" + digest)
43+
}
44+
}

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,24 @@ import FoundationNetworking
1919
import HTTPTypes
2020
import Basics
2121

22-
enum RegistryClientError: Error {
22+
public enum RegistryClientError: Error {
2323
case registryParseError(String)
2424
case invalidRegistryPath(String)
2525
case invalidUploadLocation(String)
26+
case invalidDigestAlgorithm(String)
27+
case digestMismatch(expected: String, registry: String)
2628
}
2729

2830
extension RegistryClientError: CustomStringConvertible {
29-
var description: String {
31+
/// Human-readable description of a RegistryClientError
32+
public var description: String {
3033
switch self {
3134
case let .registryParseError(reference): return "Unable to parse registry: \(reference)"
3235
case let .invalidRegistryPath(path): return "Unable to construct URL for registry path: \(path)"
3336
case let .invalidUploadLocation(location): return "Received invalid upload location from registry: \(location)"
37+
case let .invalidDigestAlgorithm(digest): return "Invalid or unsupported digest algorithm: \(digest)"
38+
case let .digestMismatch(expected, registry):
39+
return "Digest mismatch: expected \(expected), registry sent \(registry)"
3440
}
3541
}
3642
}

Sources/containertool/Extensions/Errors+CustomStringConvertible.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,6 @@ extension ContainerRegistry.ImageReference.Digest.ValidationError: Swift.CustomS
8181
switch self {
8282
case .emptyString:
8383
return "Invalid reference format: digest cannot be empty"
84-
case .tooLong(let rawValue):
85-
return "Invalid reference format: digest (\(rawValue)) is too long"
8684
case .invalidReferenceFormat(let rawValue):
8785
return "Invalid reference format: digest (\(rawValue)) is not a valid digest"
8886
}

Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ extension RegistryClient {
3939
log("Layer \(digest): pushing")
4040
let uploaded = try await destClient.putBlob(repository: destRepository, data: blob)
4141
log("Layer \(digest): done")
42-
assert("\(digest)" == uploaded.digest)
42+
43+
guard "\(digest)" == uploaded.digest else {
44+
throw RegistryClientError.digestMismatch(expected: "\(digest)", registry: uploaded.digest)
45+
}
4346
}
4447
}

Sources/containertool/Extensions/RegistryClient+Layers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ extension RegistryClient {
3939
}
4040
}
4141

42-
typealias DiffID = String
42+
typealias DiffID = ImageReference.Digest
4343
struct ImageLayer {
4444
var descriptor: ContentDescriptor
4545
var diffID: DiffID

Sources/containertool/containertool.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,8 @@ extension RegistryClient {
324324
// It is used by the runtime, which might not store the layers in
325325
// the compressed form in which it received them from the registry.
326326
diff_ids: baseImageConfiguration.rootfs.diff_ids
327-
+ resourceLayers.map { $0.diffID }
328-
+ [applicationLayer.diffID]
327+
+ resourceLayers.map { "\($0.diffID)" }
328+
+ ["\(applicationLayer.diffID)"]
329329
),
330330
history: [.init(created: timestamp, created_by: "containertool")]
331331
)
@@ -375,7 +375,7 @@ extension RegistryClient {
375375
if let tag {
376376
reference = try ImageReference.Tag(tag)
377377
} else {
378-
reference = try ImageReference.Digest(manifest.digest)
378+
reference = manifest.digest
379379
}
380380
let location = try await self.putManifest(
381381
repository: destinationImage.repository,

Tests/ContainerRegistryTests/ImageReferenceTests.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,18 @@ struct ReferenceTests {
135135
)
136136
),
137137

138+
ReferenceTestCase(
139+
reference:
140+
"example.com/foo@sha512:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
141+
expected: try! ImageReference(
142+
registry: "example.com",
143+
repository: ImageReference.Repository("foo"),
144+
reference: ImageReference.Digest(
145+
"sha512:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
146+
)
147+
)
148+
),
149+
138150
ReferenceTestCase(
139151
reference: "foo:1234/bar:1234",
140152
expected: try! ImageReference(
@@ -262,3 +274,59 @@ struct ReferenceTests {
262274
)
263275
}
264276
}
277+
278+
struct DigestTests {
279+
@Test(arguments: [
280+
(
281+
digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
282+
algorithm: "sha256", value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
283+
),
284+
(
285+
digest: "sha512:"
286+
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
287+
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
288+
algorithm: "sha512",
289+
value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
290+
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
291+
),
292+
])
293+
func testParseValidDigest(digest: String, algorithm: String, value: String) throws {
294+
let parsed = try! ImageReference.Digest(digest)
295+
296+
#expect("\(parsed.algorithm)" == algorithm)
297+
#expect(parsed.value == value)
298+
#expect("\(parsed)" == digest)
299+
}
300+
301+
@Test(arguments: [
302+
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef", // short digest
303+
"foo:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // bad algorithm
304+
"sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // bad separator
305+
])
306+
func testParseInvalidDigest(digest: String) throws {
307+
#expect(throws: ImageReference.Digest.ValidationError.invalidReferenceFormat(digest)) {
308+
try ImageReference.Digest(digest)
309+
}
310+
}
311+
312+
@Test
313+
func testDigestEquality() throws {
314+
let digest1 = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
315+
let digest2 = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
316+
let digest3 =
317+
"sha512:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
318+
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
319+
320+
#expect(try ImageReference.Digest(digest1) != ImageReference.Digest(digest2))
321+
#expect(try ImageReference.Digest(digest1) != ImageReference.Digest(digest3))
322+
323+
// Same string, parsed twice, should yield the same digest
324+
let sha256left = try ImageReference.Digest(digest1)
325+
let sha256right = try ImageReference.Digest(digest1)
326+
#expect(sha256left == sha256right)
327+
328+
let sha512left = try ImageReference.Digest(digest3)
329+
let sha512right = try ImageReference.Digest(digest3)
330+
#expect(sha512left == sha512right)
331+
}
332+
}

0 commit comments

Comments
 (0)