Skip to content

Commit b8c0178

Browse files
committed
ContainerRegistry: Parse digest algorithm and value into separate fields
1 parent 63e03a5 commit b8c0178

File tree

9 files changed

+116
-30
lines changed

9 files changed

+116
-30
lines changed

Sources/ContainerRegistry/Blobs.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import struct Crypto.SHA256
1919
/// Calculates the digest of a blob of data.
2020
/// - Parameter data: Blob of data to digest.
2121
/// - Returns: The blob's digest, in the format expected by the distribution protocol.
22-
public func digest<D: DataProtocol>(of data: D) -> String {
22+
public func digest<D: DataProtocol>(of data: D) -> ImageReference.Digest {
2323
// SHA256 is required; some registries might also support SHA512
2424
let hash = SHA256.hash(data: data)
2525
let digest = hash.compactMap { String(format: "%02x", $0) }.joined()
26-
return "sha256:" + digest
26+
return try! ImageReference.Digest("sha256:" + digest)
2727
}
2828

2929
extension RegistryClient {
@@ -134,7 +134,7 @@ public extension RegistryClient {
134134
// The server's URL is arbitrary and might already contain query items which we must not overwrite.
135135
// The URL could even point to a different host.
136136
let digest = digest(of: data)
137-
let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest.utf8)")])
137+
let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest)")])
138138

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

156156
/// 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
}

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enum RegistryClientError: Error {
2323
case registryParseError(String)
2424
case invalidRegistryPath(String)
2525
case invalidUploadLocation(String)
26+
case invalidDigestAlgorithm(String)
2627
}
2728

2829
extension RegistryClientError: CustomStringConvertible {
@@ -31,6 +32,7 @@ extension RegistryClientError: CustomStringConvertible {
3132
case let .registryParseError(reference): return "Unable to parse registry: \(reference)"
3233
case let .invalidRegistryPath(path): return "Unable to construct URL for registry path: \(path)"
3334
case let .invalidUploadLocation(location): return "Received invalid upload location from registry: \(location)"
35+
case let .invalidDigestAlgorithm(digest): return "Invalid or unsupported digest algorithm: \(digest)"
3436
}
3537
}
3638
}

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+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+
}

Tests/ContainerRegistryTests/SmokeTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,13 @@ struct SmokeTests {
183183

184184
let _ = try await client.putManifest(
185185
repository: repository,
186-
reference: ImageReference.Digest(test_manifest.digest),
186+
reference: test_manifest.digest,
187187
manifest: test_manifest
188188
)
189189

190190
let manifest = try await client.getManifest(
191191
repository: repository,
192-
reference: ImageReference.Digest(test_manifest.digest)
192+
reference: test_manifest.digest
193193
)
194194
#expect(manifest.schemaVersion == 2)
195195
#expect(manifest.config.mediaType == "application/vnd.docker.container.image.v1+json")

0 commit comments

Comments
 (0)